# Typed client (/docs/frameworks/client) > createIamClient gives browser and Node code a typed API inferred from your server instance, with a session store and passkey helpers. `@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](/docs/frameworks) does those. | Export | Entry | What it does | | ------------------------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `createIamClient(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 | The umbrella package exposes the same entries as `better-iam/client`, `better-iam/client/session`, and `better-iam/client/passkeys`. The client also exports types: `ClientOptions` (the options below), `ClientCallOptions` (per-call options), `IamClient` (the client's full type, for props and context), `ClientApi` (the per-group method map), and `ClientTransport` (the `$request` escape hatch). ## Install [#install] npm pnpm yarn bun ```bash npm i @better-iam/client ``` ```bash pnpm add @better-iam/client ``` ```bash yarn add @better-iam/client ``` ```bash bun add @better-iam/client ``` Or install the umbrella package, `better-iam`, and import from `better-iam/client`. ## Setup [#setup] ### Serve the IAM HTTP API [#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](/docs/frameworks/nextjs), [Nuxt](/docs/frameworks/nuxt), [SvelteKit](/docs/frameworks/sveltekit), [React Router](/docs/frameworks/react-router), [NestJS](/docs/frameworks/nestjs), or [Express, Hono, and Fastify](/docs/frameworks/node). A plain Node server can use `createServer(iam.nodeHandler)`. ### Create one client [#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. ```ts title="lib/iam-client.ts" import { createIamClient } from '@better-iam/client'; import type { iam } from './iam.js'; export const client = createIamClient({ baseURL: 'https://identity.example.com', onUnauthenticated: () => router.push('/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 [#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: ```ts 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 [#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 credential is the session 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('plugins/my-plugin/action', input)` calls any path relative to the IAM `basePath`. 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](/docs/guides/authentication/http) covers the wire format, cookies, and trusted origins. ### Sign-in flows [#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](#passkeys). ```ts 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](/docs/guides/authentication/mfa) lists every second-factor path, and [Sign-in methods](/docs/guides/authentication/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. * **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 [#options] `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 step-up would lose the person's work. Handle them per call. ## Errors [#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`: 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 | ```ts import { IamClientError } from '@better-iam/client'; 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 not 16 to 512 letters, digits, `_`, and `-`. * `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](/docs/reference/errors) lists the server's codes. ## Session store [#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. ```ts title="session.ts" import { createIamClient } from '@better-iam/client'; import { createSessionStore } from '@better-iam/client/session'; import type { iam } from './iam.js'; const client = createIamClient(); 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.identity.name : status); }); 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 [#passkeys] A passkey 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. **Register:** Registration needs a recently authenticated session. ```ts import { startRegistration } from '@better-iam/client/passkeys'; 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 }); ``` **Sign in:** Without an `email`, the options name no credential and the authenticator's discoverable passkey picks the account, which is what browser autofill uses. ```ts import { startAuthentication } from '@better-iam/client/passkeys'; const begun = await client.auth.beginPasskeyAuthentication({ tenantId }); // or { tenantId, email } const response = await startAuthentication({ optionsJSON: begun.options, useBrowserAutofill: true, // omit for a button-triggered ceremony }); await client.auth.finishPasskeyAuthentication({ tenantId, challengeId: begun.challengeId, response, }); ``` **Second factor:** A registered passkey can answer the MFA challenge of a password sign-in (`passkeyAvailable: true`). ```ts import { startAuthentication } from '@better-iam/client/passkeys'; const begun = await client.auth.beginPasskeyMfa({ tenantId, challenge: result.challenge }); const response = await startAuthentication({ optionsJSON: begun.options }); await client.auth.finishPasskeyMfa({ tenantId, challengeId: begun.challengeId, response, rememberDevice: true, // optional }); ``` [Passkeys](/docs/guides/authentication/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 advisory: use them to decide what to show, and enforce on the server. ## Next steps [#next-steps] - [React](/docs/frameworks/react): `IamProvider` and hooks on top of this client. - [Vue](/docs/frameworks/vue): The Vue plugin and composables. - [API reference](/docs/reference/api): Every group and method the client can call. # Frameworks (/docs/frameworks) > What each framework integration does for you, and how the typed client, React, Vue, Next.js, Nuxt, SvelteKit, React Router, NestJS, and Node ones compare. Every framework integration wraps the same `betterIam()` instance. None of them has its own session format, policy engine, or audit trail. They read the session the server issued, ask the server for decisions, and call the server's HTTP handler in process when they need to sign someone in. A page guarded in Next.js, a NestJS controller, and an Express route therefore enforce the same roles, policies, conditions, boundaries, and relationships, and write the same audit events. ## Why use an integration [#why-use-an-integration] You can always call the core directly: mount `iam.handler`, pass a request's headers as the credential to `iam.api.*`, and call `iam.require` before a mutation. The integrations exist because every web framework needs the same glue around those calls, and the glue is where mistakes creep in: * **Getting the credential.** Each request carries a session cookie or a bearer token. The integration reads it the way your framework exposes requests, and looks the session up once per request instead of once per call. * **Answering refusals the framework's way.** A signed-out visitor on a page should be redirected to the login page with a safe `?next=`. The same refusal on an API route should be a 401 JSON body, and in a form action it should come back to the form. The integrations map every IAM refusal to the right shape. * **Setting cookies from the server.** When a server-side form signs someone in, the new session cookie has to land on the response with the attributes the server chose. The integrations run sign-in through the IAM HTTP handler in process and copy its `Set-Cookie` headers through your framework's cookie API. * **Cross-site protection.** Browsers attach cookies automatically, even to a form another site's page submits, so a cookie-authenticated `POST` must be checked for its `Origin` (a CSRF check). Most server integrations run the same rule the IAM handler uses; [Cross-site checks](#cross-site-checks) lists where each one does it. * **Fast, consistent UI.** Permission checks for a page full of buttons are batched into one call. Sessions and decisions loaded on the server are handed to the browser, so the first render needs no extra requests. It also matches the server's HTML, so there is no hydration mismatch (a browser render that differs from the HTML). ## Pick an integration [#pick-an-integration] Choose the package for the framework your application runs on. The browser packages (the typed client, React, and Vue) decide what to render and pair with any server. The full-stack and server packages mount the API, read sessions on the server, and enforce access. How to read the columns: * **Session on the server**: how server code reads the signed-in person. * **Guards**: what enforces access, or in the browser, decides what to show. * **Mutations**: where state changes are checked. * **Server rendering**: how a session loaded on the server reaches the browser for hydration. | Stack | Package (umbrella subpath) | Session on the server | Guards | Mutations | Browser | Server rendering | | ---------------------- | -------------------------------------------------------------------- | ------------------------------ | --------------------------------------------- | ----------------------------------- | ------------------------------------- | --------------------------- | | Any browser app | `@better-iam/client` (`better-iam/client`) | not applicable | not applicable | every API method | typed client, session store, passkeys | not applicable | | React | `@better-iam/react` (`better-iam/react`) | not applicable | `Can`, `useAuthorize` (advisory) | through the client | `IamProvider`, hooks | `initialSession` | | Vue | `@better-iam/vue` (`better-iam/vue`) | not applicable | `IamCan`, `useCan` (advisory) | through the client | plugin, composables | `createHydration` | | Next.js | `@better-iam/next` (`better-iam/next`, `/edge`, `/client`) | `getSession`, `requireSession` | `page`, `route`, `apiRoute`, middleware | `action`, `authActions`, `client()` | `IamNextProvider` | `sessionForClient` | | Nuxt | `@better-iam/nuxt` (not in the umbrella), `/h3` | `getIamSession(event)` | `definePageMeta({ iam })`, `requireIamAccess` | server routes | auto-imported composables, `IamCan` | automatic payload hydration | | SvelteKit | `@better-iam/svelte` (`better-iam/svelte`, `/kit`) | `locals.iam.getSession()` | `protect` rules, `guard` | `action` returns `fail()` | Svelte stores | `sessionData`, `initial` | | React Router | `@better-iam/react-router` (`better-iam/react-router`) | `helpers(args).getSession()` | `guard` | `action` returns `data()` | `@better-iam/react` | `sessionData` | | NestJS | `@better-iam/nestjs` (`better-iam/nestjs`) | `@CurrentPrincipal()` | `IamGuard`, `@Authorize` | `IamService.require` | not applicable | not applicable | | Express, Hono, Fastify | `@better-iam/middleware` (`better-iam/express`, `/hono`, `/fastify`) | `req.iam.getSession()` | `requireSession()`, `authorize()` | `req.iam.require()` | not applicable | not applicable | The umbrella package `better-iam` installs everything and exposes each integration as a subpath. The scoped packages (`@better-iam/next`, ...) are the same code for applications that install only what they use. `@better-iam/nuxt` is the exception: install it directly, because it is a Nuxt module and is not part of the umbrella. See [Installation](/docs/guides/installation) for the full import map. ## How they share one core [#how-they-share-one-core] Browser code reaches the instance over HTTP, through the API your server mounts. Server code (guards, loaders, actions) calls the same instance directly in the same process, with no network hop. The server-side integrations are built from the same pieces. Not every integration has every piece; the notes in each item say which ones do. * **A mounted HTTP API.** The browser client needs somewhere to send its calls. The IAM handler serves `POST {basePath}/{group}/{method}` (default `/api/iam`), plus `/health`, `/metrics`, and the OAuth and OpenID Connect, SAML, and SCIM protocol mounts. Next.js exports it from a catch-all route handler, Nuxt mounts it as a Nitro route, SvelteKit serves it from `handle`, React Router from a resource route, NestJS mounts it as middleware, and the Node adapters answer it before your routes run. * **Sessions from the request.** Server helpers read the session cookie (`__Host-better-iam.session` on HTTPS, `better-iam.session` on loopback HTTP) or a bearer token, and memoize the lookup for the request. Missing, expired, revoked, and step-up-required credentials read as signed out instead of throwing. * **Server-side decisions.** Guards enforce with `iam.require` (NestJS records each rule with `iam.authorize`). Batched advisory checks, such as Next.js `allowed()` and `req.iam.can()`, go through `authorizeMany` with at most 50 checks per call and one call per tenant. The resource defaults to the tenant itself (`iam/{tenantId}`) and the tenant to the session's. * **An in-process client.** Next.js, SvelteKit, React Router, and the Node adapters give server code the full typed client with `iam.handler` as its transport. It forwards the caller's cookies and forwarding headers, sets `Origin` to the IAM origin, and writes the `Set-Cookie` headers the server returns through the framework's own cookie API. That is what lets a plain `
` sign someone in without client JavaScript. Nuxt's server rendering uses a smaller bound client (session and decisions only), and NestJS has `IamService` instead. * **CSRF checks.** Cookie-authenticated `POST`, `PUT`, `PATCH`, and `DELETE` requests must come from the application's own origin (or `Sec-Fetch-Site: same-origin`), the IAM origin, or a configured trusted origin. Bearer credentials skip the check because browsers never attach them on their own. See [Cross-site checks](#cross-site-checks) for where each integration applies it. * **Step-up.** `stepUp: { mfa?: true | 'fresh', maxAgeMs?: number }` asks for step-up authentication the same way in the Next.js, SvelteKit, React Router, and Node guards, through the shared `checkStepUp` rules. Failures carry `MFA_REQUIRED`, `RECENT_AUTH_REQUIRED`, or `IMPERSONATION_RESTRICTED` and a `reason` of `mfa`, `recent`, or `impersonation`. NestJS offers `@RequireMfa()`. * **Safe redirects.** `safeRedirectPath(value, fallback)` (Next.js, SvelteKit, React Router, and the Node adapters) accepts only same-origin paths, so a `?next=` parameter cannot become an open redirect. > **Rendering decisions are advisory.** `Can`, `IamCan`, `useAuthorize`, the Svelte stores, and `iamNext.allowed()` make advisory decisions: they decide what to render. Enforce the operation itself on the server, immediately before performing it, with a guard or `iam.require`, and load resource ownership from trusted storage. ## Cross-site checks [#cross-site-checks] A CSRF (cross-site request forgery) attack makes a signed-in visitor's browser send a request to your application from another site's page, and the browser attaches the session cookie on its own. Session cookies are `SameSite=Lax` by default, which blocks most of these. Browsers still send them with form posts from sibling subdomains (`evil.example.com` posting to `app.example.com`), though. The IAM HTTP API refuses such requests itself. Your own routes need the same check, and this is where each integration provides it: | Integration | Where the `Origin` of cookie-authenticated mutations is checked | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | | Typed client, React, Vue | Not applicable: they run in the browser. The client sends the `X-Better-IAM: 1` header the IAM API requires | | Next.js | `route()`, `apiRoute()`, and `pages.api()`. Server actions are checked by Next itself | | Nuxt | The mounted IAM API only. Check your own state-changing server routes yourself ([how](/docs/frameworks/nuxt#cross-site-requests)) | | SvelteKit | SvelteKit's own origin check for form posts (`csrf.checkOrigin`, on by default) | | React Router | `guard()` and `action()` (the `csrf` option) | | NestJS | `IamGuard` (the `csrf` module option) | | Express, Hono, Fastify | `requireSession()` and `authorize()` guards (the `csrf` option); `checkRequestOrigin` for unguarded routes | ## Packages [#packages] Each card shows the package's version, its description, its subpath entries, and the framework versions it expects (peer dependencies). ## Choose your stack [#choose-your-stack] - [Typed client](/docs/frameworks/client): `createIamClient` for any browser or Node code, the session store, and passkey helpers. - [React](/docs/frameworks/react): `IamProvider`, `useSession`, `useAuthorize`, `Can`, and self-service hooks. - [Vue](/docs/frameworks/vue): The Vue plugin, composables, `IamCan`, and server-rendering hydration. - [Next.js](/docs/frameworks/nextjs): App Router guards, server actions, auth forms, middleware, and the Pages Router. - [Nuxt](/docs/frameworks/nuxt): A Nuxt module with page meta guards, server utilities, and hydrated sessions. - [SvelteKit](/docs/frameworks/sveltekit): A `handle` hook, `locals.iam`, guarded loads and form actions, and Svelte stores. - [React Router](/docs/frameworks/react-router): Framework-mode middleware, guarded loaders and actions, and the API resource route. - [NestJS](/docs/frameworks/nestjs): A module, a guard, `@Authorize` decorators, audit event handlers, and assertions. - [Express, Hono, Fastify](/docs/frameworks/node): Middleware that serves the API, adds `req.iam`, and guards routes. # NestJS (/docs/frameworks/nestjs) > IamModule, a guard that authenticates every request, @Authorize and other decorators, audit event handlers, assertions, and testing for NestJS 11 and 12. `@better-iam/nestjs` integrates Better IAM with NestJS 11 and 12, on Express or Fastify. Nest applications describe access with guards and decorators, and this package lets you do the same. Instead of calling `iam.authenticate` and `iam.authorize` at the top of every handler, you declare `@Authorize('projects:read', ...)`. A global guard enforces it before the handler runs, with the right status codes and audit records. Compared with calling the core yourself, the guard takes care of: * **Sessions on the server.** It finds the credential on HTTP requests, GraphQL contexts, and WebSocket handshakes, and authenticates it once per request (once per message on gateways). Parameter decorators hand the result to your handler. * **Access rules.** Decorators declare the credential kind, MFA, and permissions a handler needs, and every rule is a recorded decision. * **Cross-site checks.** Cookie-authenticated `POST`, `PUT`, `PATCH`, and `DELETE` requests from another site's page are refused before the handler runs ([details](#what-the-guard-does-in-order)). * **Errors.** Refusals render as the IAM `{ error: { code, message } }` body with the right status. There is no hydration step: Nest serves APIs, and a browser front end loads its session through the [typed client](/docs/frameworks/client). The package gives you: * **`IamModule`**, which provides everything below and can serve the IAM HTTP API from your Nest app. * **`IamGuard`**, which authenticates every request (HTTP, GraphQL, WebSocket gateways) and enforces the handler's decorators. * **Decorators**: `@Authorize`, `@Public`, `@RequireMfa`, `@Credentials`, `@FilterAccessible`, and parameter decorators for the principal, identity, session, and tenant. * **`IamService`**, request-scoped IAM calls for controllers and providers. * **`@OnIamEvent`** handlers for committed audit events, **`IamAssertionModule`** for downstream services, and **`createTestingIam`** for tests without a database. ## Install [#install] npm pnpm yarn bun ```bash npm i @better-iam/nestjs better-iam ``` ```bash pnpm add @better-iam/nestjs better-iam ``` ```bash yarn add @better-iam/nestjs better-iam ``` ```bash bun add @better-iam/nestjs better-iam ``` The umbrella exposes the package as `better-iam/nestjs` and `better-iam/nestjs/testing`. Its peers are `@nestjs/common` and `@nestjs/core` 11 or 12, `reflect-metadata`, and `rxjs`. ## Setup [#setup] ### Create the instance [#create-the-instance] ```ts title="src/iam.ts" import { betterIam } from '@better-iam/server'; import { sqliteAdapter } from '@better-iam/adapter-sqlite'; export const iam = betterIam({ database: sqliteAdapter({ filename: process.env.BETTER_IAM_DATABASE ?? 'iam.db' }), secret: process.env.BETTER_IAM_SECRET!, baseURL: process.env.BETTER_IAM_BASE_URL ?? 'http://localhost:3000', permissions: { resourceTypes: { // Registered with IAM, so list endpoints can use the listAccessible reverse query. project: { managed: true, actions: ['projects:read', 'projects:manage'] }, }, }, }); ``` ### Import the module [#import-the-module] ```ts title="src/app.module.ts" import { Module } from '@nestjs/common'; import { IamModule } from '@better-iam/nestjs'; import { iam } from './iam.js'; import { ProjectsController } from './projects.controller.js'; @Module({ imports: [ IamModule.forRoot({ iam, guard: true, // every route needs a session unless @Public() mount: true, // serve /api/iam/* (sign-in, admin API, OAuth/SAML/SCIM mounts) from Nest dispatchIntervalMs: 1000, // deliver audit events to @OnIamEvent handlers }), ], controllers: [ProjectsController], }) export class AppModule {} ``` ### Create the app with `rawBody` [#create-the-app-with-rawbody] ```ts title="src/main.ts" import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module.js'; import { iam } from './iam.js'; await iam.initialize(); // rawBody lets the IAM mount forward form posts (SAML) byte for byte. const app = await NestFactory.create(AppModule, { rawBody: true }); app.enableShutdownHooks(); // unsubscribes @OnIamEvent handlers and stops the dispatch timer await app.listen(3000); ``` ### Declare access on controllers [#declare-access-on-controllers] ```ts title="src/projects.controller.ts" import { Controller, Get, Param, Post } from '@nestjs/common'; import type { Identity } from '@better-iam/core'; import { Authorize, CurrentIdentity, FilterAccessible, Public, RequireMfa, TenantId } from '@better-iam/nestjs'; @Controller() export class ProjectsController { constructor(private readonly projects: ProjectsService) {} @Public() @Get('status') status() { return { ok: true }; } @Get('projects') @FilterAccessible('projects:read', { type: 'project' }) // drops projects the caller may not read list() { return this.projects.findAll(); } @Get('projects/:id') @Authorize('projects:read', { resource: { type: 'project', id: { param: 'id' } } }) read(@Param('id') id: string, @TenantId() tenantId: string, @CurrentIdentity() me: Identity) { return this.projects.find(id); } @Post('projects/:id/archive') @RequireMfa() @Authorize('projects:manage', { resource: { type: 'project', id: { param: 'id' } } }) archive(@Param('id') id: string) { return this.projects.archive(id); } } ``` `IamModule` is global by default, so any module can inject `IamService` without importing it again. ## What the guard does, in order [#what-the-guard-does-in-order] 1. **Find the credential.** HTTP uses the request (Express or Fastify). GraphQL uses `context.req`, `context.request`, or `context.reply.request` (Apollo and Mercurius). WebSocket gateways use the socket.io handshake (`client.handshake`, `client.request`). Other transports have no HTTP credential and are refused unless the handler is `@Public()`. 2. **Authenticate** with `iam.authenticate`, which accepts session cookies, bearer session tokens, API keys, and assumed-role credentials. On a `@Public()` handler, a missing or invalid credential yields a `null` principal instead of an error. The result, the principal, is stored per request (per message for gateways) for the parameter decorators and later guards. 3. **CSRF** (HTTP only). Browsers attach session cookies to cross-site form posts, so cookie-authenticated `POST`, `PUT`, `PATCH`, and `DELETE` requests must come from the request's own host (`Origin`, or `Sec-Fetch-Site: same-origin`) or from `csrf.trustedOrigins`. Bearer credentials skip this check because browsers never attach them implicitly, and so do non-browser clients that send neither `Origin` nor `Sec-Fetch-Site`. 4. **Credential kind and MFA:** `@Credentials(...)` and `@RequireMfa()`. 5. **Authorization:** every `@Authorize` rule on the class, then on the method, through `iam.authorize`. Each rule is a separate, recorded decision, so denials and root overrides are audited exactly as elsewhere. The first denial stops the request. | Failure | Status | `error.code` | | ---------------------------------- | ------------ | ----------------------------------------------------- | | No credential, expired, revoked | 401 | `UNAUTHENTICATED` | | Step-up required by tenant policy | 403 | `MFA_REQUIRED` | | `@RequireMfa()` on a non-MFA login | 403 | `MFA_REQUIRED` | | `@Credentials()` mismatch | 403 | `CREDENTIAL_NOT_ALLOWED` | | Cross-site cookie request | 403 | `CSRF_REJECTED` | | Any rule denies | 403 | `ACCESS_DENIED` | | Missing resource id for a rule | 400 | `INVALID_INPUT` | | Inactive tenant, rate limits, ... | the server's | the server's code (`TENANT_INACTIVE`, `RATE_LIMITED`) | Guard failures are `HttpException`s with the IAM `{ error: { code, message } }` body, so they render correctly with or without `IamExceptionFilter`. The filter covers `IamError`s thrown later, inside handlers and providers. ## Decorators [#decorators] Method and class decorators tell `IamGuard` what a handler requires; parameter decorators hand the handler what the guard found. | Decorator | What it does | | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@Authorize(action, { resource?, tenant? })` | Enforces `action` before the handler runs. Class and method rules accumulate, and every one must allow. The resource defaults to the tenant (`iam/{tenantId}`) | | `@Public()` | No session is required. A valid credential is still resolved, and invalid ones are ignored | | `@RequireMfa()` | The session must have completed multi-factor authentication | | `@Credentials(...kinds)` | Accepts only these session kinds: `user`, `api-key`, or `role`. `@Credentials('api-key')` marks a machine-only endpoint | | `@FilterAccessible(action, { type, id?, path?, tenant? })` | Removes the items the caller may not act on from the handler's array result (or `result[path]`) | | `@CurrentPrincipal()` | The authenticated `{ identity, session }`, or `null` on a public route without a credential | | `@CurrentIdentity()` | The caller's identity, or `null` | | `@CurrentSession()` | The caller's session record, or `null` | | `@TenantId()` | The tenant the request's `@Authorize` rules were evaluated in, or the session's tenant when there were none | | `@OnIamEvent(pattern \| patterns)` | Subscribes a provider method to audit events; see [Audit events](#audit-events) | `resource` is `{ type, id }` where `id` is a value source, or a function `(request, principal) => ({ type, id })`. A value source is one of: | Source | Reads | | ------------------------------- | ---------------------------------------------------------------- | | `'fixed-id'` | A fixed string | | `{ param: 'id' }` | A route parameter | | `{ query: 'id' }` | A query string key | | `{ body: 'projectId' }` | A body field | | `{ header: 'x-project' }` | A header | | `{ arg: 'id' }` | A GraphQL resolver argument, or a field of the WebSocket message | | `(request, principal) => value` | Anything your code computes, sync or async | ### Tenancy [#tenancy] A rule is evaluated in exactly one tenant. The guard resolves it in this order: 1. The rule's own `tenant` source. 2. The module's `tenant` option. 3. The `tenantId` route parameter, then the `x-tenant-id` header. 4. The tenant of the caller's session. ```ts // Path-scoped APIs: /orgs/:tenantId/projects/:id (the default picks up :tenantId). @Authorize('projects:read', { resource: { type: 'project', id: { param: 'id' } } }) // Subdomain tenancy, resolved once for the whole app. IamModule.forRoot({ iam, tenant: async (request) => tenantIdForHost(new Headers(request.headers as HeadersInit).get('host')), }); // A resource that knows its own tenant: resolve both from your storage. @Authorize('invoices:approve', { tenant: async (request) => (await invoices.get(request.params!.id as string)).tenantId, resource: { type: 'invoice', id: { param: 'id' } }, }) ``` Resolving the tenant from the request never widens access. The decision still checks that the principal holds a grant in that tenant, and a session from one organization has no grants in another. ### GraphQL and gateways [#graphql-and-gateways] Resolvers use the same decorators. `{ arg: 'name' }` reads a resolver argument; on a gateway, it reads a field of the incoming message. ```ts title="src/projects.resolver.ts" @Resolver(() => Project) export class ProjectsResolver { @Query(() => Project) @Authorize('projects:read', { tenant: { arg: 'tenantId' }, resource: { type: 'project', id: { arg: 'id' } }, }) project(@Args('tenantId') tenantId: string, @Args('id') id: string) {} } ``` Gateways authenticate from the handshake but authorize each message separately. The principal is re-resolved for every message, so a revoked session stops working at the next message, not at reconnect. Put `@UseGuards(IamGuard)` on each gateway explicitly: `guard: true` is written for HTTP controllers and resolvers, and whether a global guard also reaches gateways depends on the Nest version and application type. ### List endpoints [#list-endpoints] `@Authorize` protects one resource. To list resources, either query IAM first or filter afterwards: * **Query first.** `IamService.listAccessible(request, { tenantId, action, type })` returns the registered resources the caller may act on. Use their `resourceId`s in your database query, which keeps pagination correct. * **Filter afterwards.** `@FilterAccessible(action, { type })` removes inaccessible items from the handler's result (or `result[path]`; `id` picks each item's id, default `item.id`). It's simpler to adopt, but a page can come back shorter than requested. Both use the reverse query ([how it works](/docs/guides/authorization/queries)), never one decision per item, so the audit log does not fill with denials; `@FilterAccessible` pages through it 1000 registered resources at a time. Both apply only to managed (registered) resource types, and `@FilterAccessible` always drops unregistered items. For resources resolved through `resolveResource`, use `IamService.can` with at most 50 checks per call. ## IamService [#iamservice] `IamService` makes request-scoped calls: every method takes the incoming request (Express, Fastify, or anything with `headers`) and forwards its credential, so decisions are always made for the actual caller. | Method | What it does | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `principal(request)` | The caller's `{ identity, session }`, or `null` when the request carries no usable credential | | `authorize(request, { tenantId, action, resource })` | A recorded decision; never throws for a denial | | `require(request, { tenantId, action, resource })` | Throws the server's `ACCESS_DENIED` error (403) unless allowed | | `can(request, { tenantId, checks })` | Advisory decisions for UI state, keyed `${action}@${type}/${id}`; all false when unauthenticated | | `listAccessible(request, { tenantId, action, type, limit?, offset? })` | The registered resources of a managed type the caller may act on | | `assertion(request, { tenantId, audience, ttlSeconds?, claims? })` | A short-lived signed assertion for a downstream service | | `credential(request)` | The caller's credential for any `iam.api.*` call | | `health()` | `{ status: 'up' \| 'down', latencyMs? }` from the server's `/health`, in process: for Terminus or a readiness route | | `iam` | The underlying instance, for administrative calls with an explicit credential | ```ts @Get() async list(@Req() request: Request, @TenantId() tenantId: string) { const { resources } = await this.iam.listAccessible(request, { tenantId, action: 'projects:read', type: 'project' }); const flags = await this.iam.can(request, { tenantId, checks: [{ action: 'projects:create' }] }); return { resources, canCreate: flags[`projects:create@iam/${tenantId}`] }; } ``` ## Module options [#module-options] `IamModule.forRoot(options)` takes the options below. `forRootAsync`, described after the table, builds the same options from other providers, such as a configuration service. `forRootAsync` builds the options from other providers: `useFactory` with `inject` and `imports`, `useClass`, or `useExisting`, where the class implements `IamOptionsFactory` (`createIamOptions()`). The switches that shape the module graph (`guard`, `mount`, `filter`, `global`) are static arguments in both forms. ```ts IamModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ iam, csrf: { trustedOrigins: [config.getOrThrow('ADMIN_ORIGIN')] }, }), guard: true, mount: true, }); ``` Besides the module, decorators, and service, the package exports the pieces the module wires together, for custom setups: * `IamGuard`: the guard itself, for `@UseGuards(IamGuard)`. * `IamExceptionFilter`: the filter that renders `IamError`s ([Errors](#errors)). * `IamFilterInterceptor`: the interceptor behind `@FilterAccessible`. * `IamHttpMiddleware` and `createIamRequestHandler`: the API mount ([below](#serving-the-iam-api-from-nest)). * `IamEventsExplorer`: subscribes `@OnIamEvent` methods at bootstrap and runs the dispatch timer; its `dispatch()` delivers queued events now. * The injection tokens `IAM_INSTANCE` (the instance), `IAM_OPTIONS` (the resolved module options), and `IAM_ASSERTION_OPTIONS` (the options of `IamAssertionModule`). ## Serving the IAM API from Nest [#serving-the-iam-api-from-nest] With `mount: true`, the IAM handler serves `mountPath` as Nest middleware, so the browser client, OAuth/OIDC, SAML, and SCIM mounts share your app's port. The handler keeps its own protections: the `X-Better-IAM` header, exact trusted origins for cookie requests, and body limits. * **Bodies.** Nest's body parsers usually consume the body before middleware runs. With `NestFactory.create(App, { rawBody: true })`, form posts (SAML ACS) pass through unchanged; without it, the mount re-serializes the parsed body. Bodies over 2 MiB answer 413 `PAYLOAD_TOO_LARGE`. * **Global prefix.** Under `app.setGlobalPrefix('v1')`, give `mountPath` relative to the prefix, because Nest prefixes middleware routes too. * **Custom setups.** `createIamRequestHandler(iam)` returns a plain `(req, res, next)` handler for `app.use('/api/iam', ...)`; `IamHttpMiddleware` is its Nest middleware form. ## Errors [#errors] `IamExceptionFilter` is registered globally by default (`filter: false` turns it off). It renders `IamError`s thrown in handlers, for example by `IamService.require` or direct `iam.api.*` calls, with their status and code instead of a 500, and sets `Cache-Control: no-store`. GraphQL gets an `HttpException`, WebSocket clients get an `exception` event, and RPC callers get an error payload. `toHttpException(error)`, `isIamError(error)`, `isAuthenticationError(error)`, and `credentialOf(request)` are exported for your own code. ## Audit events [#audit-events] Your application often has to react when access changes: clean up data when an identity is deleted, notify a team when someone is invited. Every IAM change is an audit event, and a provider method can run for each one that matches a pattern. ```ts title="src/audit.listener.ts" import { Injectable, Logger } from '@nestjs/common'; import type { AuditEvent } from '@better-iam/core'; import { OnIamEvent } from '@better-iam/nestjs'; @Injectable() export class AuditListener { private readonly logger = new Logger('Audit'); @OnIamEvent(['identity:*', 'iam:identities:create']) record(event: AuditEvent) { this.logger.log(`${event.action} ${event.outcome} by ${event.actorId}`); } } ``` `@OnIamEvent(pattern | patterns)` subscribes a provider method to audit events, using the same action globs as webhooks (`identity:*`, `iam:identities:*`). Handlers are subscribed at bootstrap and unsubscribed at shutdown. List the listener class in a module's `providers` (the example registers `AuditListener` in `AppModule`), so Nest creates it and the module finds its methods. Providers with request or transient scope are skipped. * **Delivery.** Events are delivered after the transaction commits, at least once. Make handlers idempotent: `event.id` is stable across retries. * **Who dispatches.** Delivery happens when something calls `iam.events.dispatch()`. `dispatchIntervalMs` runs it inside the app on a timer, one run at a time. For multi-instance deployments, dispatch from a single worker and leave the option unset elsewhere. See [Lifecycle events](/docs/guides/events/lifecycle-events) and [Webhooks](/docs/guides/events/webhooks) for the event catalog and patterns. ## Microservices: assertions instead of shared sessions [#microservices-assertions-instead-of-shared-sessions] A gateway or backend-for-frontend authenticates the user and issues a short-lived assertion for each downstream call, so downstream services never need the IAM database or the user's session: ```ts const { token } = await this.iam.assertion(request, { tenantId, audience: 'billing', ttlSeconds: 60 }); await fetch('http://billing/invoices', { headers: { authorization: `Bearer ${token}` } }); ``` The billing service verifies it offline: ```ts title="billing/src/app.module.ts" import { IamAssertionModule } from '@better-iam/nestjs'; @Module({ imports: [ IamAssertionModule.forRoot({ key: process.env.IAM_ASSERTION_KEY!, // iam.assertionKey() of the issuing deployment audience: 'billing', issuer: 'https://iam.example.com', guard: true, }), ], }) export class BillingModule {} @Controller('invoices') export class InvoicesController { @Get() @RequireClaims({ roles: ['role_billing_admin'], mfa: true }) list(@AssertionClaims() claims: { sub: string; tid: string; roles: string[] }) {} } ``` | Piece | What it does | | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `IamAssertionModule.forRoot({ key, audience, issuer?, toleranceSeconds?, header?, guard?, global? })` | Configures verification. `key` is `iam.assertionKey()` (64 hex characters) or the list from `iam.assertionKeys()` during secret rotation; `toleranceSeconds` defaults to 30; `header` defaults to `authorization` with the `Bearer` scheme | | `IamAssertionGuard` | Verifies the token (signature, audience, issuer, lifetime) without a database or network call, then checks `@RequireClaims`. Installed globally with `guard: true`; honors `@Public()` | | `@RequireClaims({ roles?, groups?, mfa?, kinds? })` | Requires at least one of the roles, at least one of the groups, an MFA session, or one of the credential kinds; otherwise 403 `ACCESS_DENIED` | | `@AssertionClaims()` | The verified claims (`sub`, `tid`, `roles`, `groups`, `ext`, ...), or `null` on a public handler | The caller needs `iam:assertions:create` on `iam/billing`, so administrators decide which services each role may call. This path imports only `@better-iam/server/assertions`, so the verifying service never loads the IAM server or its native dependencies. Keep the TTL short: the downstream service cannot see revocations before an assertion expires. ## Testing [#testing] Pick the level by what the test must prove: controller wiring and decorators need no database, while policy behavior needs a real instance. | Level | Use | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | Unit and controller e2e | `createTestingIam` from `@better-iam/nestjs/testing`: principals by bearer token, decisions from a callback, a `decisions` log, `emit()` for listeners | | Policy-accurate e2e | A real `betterIam()` with `sqliteAdapter({ filename: ':memory:' })`. Bootstrap, create a tenant and members through `iam.api`, and sign in to get tokens | | Guards in isolation | `new IamGuard(new Reflector(), { iam })` with `ExecutionContextHost` from `@nestjs/core/helpers/execution-context-host.js` | ```ts title="test/projects.e2e.ts" import { Test } from '@nestjs/testing'; import { IamModule } from '@better-iam/nestjs'; import { createTestingIam } from '@better-iam/nestjs/testing'; const iam = createTestingIam({ principals: { alice: { identity: { email: 'alice@example.test', tenantId: 't1' } }, admin: { identity: { tenantId: 't1' }, session: { mfa: true } }, ci: { identity: { kind: 'service', tenantId: 't1' } }, // an API key session }, decide: ({ principal, action, resource }) => principal.session.mfa || (action === 'projects:read' && resource.id === 'apollo'), resources: { project: ['apollo', 'gemini'] }, // for listAccessible and @FilterAccessible }); const moduleRef = await Test.createTestingModule({ imports: [IamModule.forRoot({ iam, guard: true })], controllers: [ProjectsController], }).compile(); // Requests with `authorization: Bearer alice` now run as Alice. ``` `createTestingIam` uses no database and no password hashing. `iam.decisions` records every check with its outcome, `iam.principal(token)` returns the principal a token resolves to, and `iam.emit({ action: 'identity:create' })` delivers an event to `@OnIamEvent` handlers. It serves no HTTP API and does not issue assertions. For end-to-end tests against real policies, use a real instance instead. Library code uses explicit `@Inject()` tokens, so the integration also works in test runners that don't emit decorator metadata (Vitest, esbuild). Your own providers need `emitDecoratorMetadata` or explicit `@Inject()` as usual. ## Next steps [#next-steps] - [Policies](/docs/guides/authorization/policies): What each `@Authorize` rule actually evaluates. - [Batches and reverse queries](/docs/guides/authorization/queries): How `listAccessible` and `@FilterAccessible` find what a caller may act on. - [Webhooks](/docs/guides/events/webhooks): Deliver the same audit events to other services. # Express, Hono, and Fastify (/docs/frameworks/node) > Middleware that serves the IAM API, adds req.iam helpers, guards routes, and maps refusals to JSON or redirects, with a core for other Node frameworks. `@better-iam/middleware` connects a `betterIam()` instance to the common Node server frameworks. You could call the core API from every route yourself. Each route would then read the session from headers, turn refusals into the right status or redirect, check the `Origin` of cookie posts, and copy the cookies a sign-in issues onto the response. The adapters do those things once, the same way in Express, Hono, and Fastify: 1. **Serve the IAM HTTP API.** Requests under the instance's `basePath` (`/api/iam`), plus any prefixes you add with `serve`, go to the IAM server. Express and Fastify use `iam.nodeHandler`, so node-only protocol mounts such as the OAuth provider work too. 2. **Add per-request helpers** as `req.iam` (Express), `c.get('iam')` (Hono), or `request.iam` (Fastify): the session, decisions, an in-process client, and sign-out. 3. **Guard routes.** `requireSession({ stepUp? })` and `authorize(action, { resource?, tenantId?, stepUp? })` check the session, then step-up, then authorization. 4. **Check where requests come from.** The same guards refuse cookie-authenticated `POST`, `PUT`, `PATCH`, and `DELETE` requests sent by another site's page ([the CSRF check](#the-csrf-check)). 5. **Map refusals.** API calls get the IAM JSON error envelope `{ error: { code, message } }` with the IAM status (401, 403, 429) and `Cache-Control: no-store`. Page navigations (a `GET` with `Accept: text/html`) are redirected to `loginPath?next=…` when signed out, or to `stepUpPath?next=…&reason=mfa|recent|impersonation` when the session must step up, if those options are set. These adapters have no browser side and no hydration step of their own. If you render Vue on the server, pass `req.iam.client` and the session to the [Vue plugin](/docs/frameworks/vue#server-rendering-and-hydration); for React, pass the session to `IamProvider` as `initialSession`. NestJS, Next.js, Nuxt, and SvelteKit have their own integrations ([NestJS](/docs/frameworks/nestjs), [Next.js](/docs/frameworks/nextjs), [Nuxt](/docs/frameworks/nuxt), [SvelteKit](/docs/frameworks/sveltekit)). SvelteKit's and [React Router](/docs/frameworks/react-router)'s are built on this same core. ## Install [#install] npm pnpm yarn bun ```bash npm i better-iam ``` ```bash pnpm add better-iam ``` ```bash yarn add better-iam ``` ```bash bun add better-iam ``` The umbrella exposes the adapters as `better-iam/express`, `better-iam/hono`, and `better-iam/fastify`, and the framework-neutral core as `better-iam/middleware`. With scoped packages, install `@better-iam/middleware` and import from `@better-iam/middleware/express`, `/hono`, `/fastify`, or the root. Express 4 and 5 and Fastify 4 and 5 are supported; the Hono adapter targets Hono 4. ## Setup [#setup] ### Type the request helpers [#type-the-request-helpers] **Express:** ```ts title="src/types.d.ts" import type { IamRequest } from 'better-iam/middleware'; import type { iam } from './iam.js'; declare global { namespace Express { interface Request { iam: IamRequest; } } } ``` **Hono:** ```ts title="src/app.ts" import { Hono } from 'hono'; import type { IamVariables } from 'better-iam/hono'; import type { iam } from './iam.js'; const app = new Hono<{ Variables: IamVariables }>(); // c.get('iam') is typed ``` **Fastify:** ```ts title="src/types.d.ts" import type { IamRequest } from 'better-iam/middleware'; import type { iam } from './iam.js'; declare module 'fastify' { interface FastifyRequest { iam: IamRequest; } } ``` ### Mount the adapter [#mount-the-adapter] **Express:** ```ts title="src/server.ts" import express from 'express'; import { createIamExpress } from 'better-iam/express'; import { iam } from './iam.js'; const iamExpress = createIamExpress(iam, { loginPath: '/login', stepUpPath: '/verify' }); const app = express(); app.use(iamExpress.middleware); // before express.json(): the IAM server reads the raw body app.use(express.json()); ``` Mount the middleware before body parsers. If a parser runs first, the adapter re-serializes the parsed body for the IAM API (up to 2 MiB; larger bodies answer 413). That works for the JSON API, but node-only protocol mounts need the untouched stream. Mounting under a path (`app.use('/api/iam', iamExpress.middleware)`) works too, because the adapter restores the path Express strips. **Hono:** ```ts title="src/app.ts" import { createIamHono } from 'better-iam/hono'; const iamHono = createIamHono(iam, { loginPath: '/login' }); app.use(iamHono.middleware); ``` Hono calls the IAM server through its fetch handler (`iam.handler`), so it runs anywhere Hono does: Node, Bun, Deno, and Workers. Node-only protocol mounts (the OAuth provider) are not reachable through it; serve those from a Node adapter. **Fastify:** ```ts title="src/server.ts" import Fastify from 'fastify'; import { createIamFastify } from 'better-iam/fastify'; const iamFastify = createIamFastify(iam, { loginPath: '/login' }); const app = Fastify(); await app.register(iamFastify.plugin); // not encapsulated, like fastify-plugin ``` The plugin answers the IAM API in an `onRequest` hook, before Fastify parses the body, and hands the raw request to `iam.nodeHandler`. Its hook and the `request.iam` decorator apply to the whole app, not an encapsulated child. ### Guard routes [#guard-routes] **Express:** ```ts app.get('/me', iamExpress.requireSession(), async (req, res) => { const session = await req.iam.getSession(); res.json({ email: session!.identity.email, canInvite: await req.iam.can('iam:identities:create') }); }); app.get( '/projects/:id', iamExpress.authorize('projects:read', { resource: (req) => ({ type: 'project', id: req.params.id }), }), (req, res) => res.json(loadProject(req.params.id)), ); ``` **Hono:** ```ts app.get('/me', iamHono.requireSession(), async (c) => c.json((await c.get('iam').getSession())!.identity)); app.get('/admin', iamHono.authorize('iam:identities:read'), (c) => c.text('admin')); ``` **Fastify:** ```ts app.get('/me', { preHandler: iamFastify.requireSession() }, async (request) => (await request.iam.getSession())!.identity, ); app.get('/admin', { preHandler: iamFastify.authorize('iam:identities:read') }, handler); ``` ### Answer refusals your routes throw [#answer-refusals-your-routes-throw] Guards answer their own refusals. Install the error handler for refusals thrown later, by `req.iam.require(...)` or direct `iam.api.*` calls inside a route. **Express:** ```ts app.use(iamExpress.errorHandler); // last: refusals become JSON or a redirect; other errors go to next(error) ``` **Hono:** ```ts app.onError(iamHono.onError); ``` `onError` answers IAM refusals and `HTTPException`s. Any other error is logged and answered with a plain 500, as Hono's default handler does. **Fastify:** ```ts app.setErrorHandler(iamFastify.errorHandler); // refusals become JSON or a redirect; other errors are rethrown ``` ## What each adapter returns [#what-each-adapter-returns] `createIamExpress`, `createIamHono`, and `createIamFastify` take the instance (or a factory that returns it) and the options below, and return the same set of members, shaped for each framework: | Member | Express | Hono | Fastify | What it does | | ------------------------------------------------------ | -------------- | ------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Mount | `middleware` | `middleware` | `plugin` | Serves the IAM API under the `serve` prefixes and attaches the helpers to every other request | | `requireSession({ stepUp? })` | middleware | middleware | `preHandler` | Requires a session, and a step-up when given | | `authorize(action, { resource?, tenantId?, stepUp? })` | middleware | middleware | `preHandler` | Requires a session allowed to perform `action`. `resource` and `tenantId` are `(req, session) => ...`; the resource defaults to the tenant (`iam/{tenantId}`) and the tenant to the session's | | Error handling | `errorHandler` | `onError` | `errorHandler` | Turns IAM refusals thrown by routes into the JSON envelope or a redirect | | `helpers(...)` | `(req, res)` | `(c)` | `(request, reply)` | The per-request helpers, created on first use | | `resolve()` | yes | yes | yes | Resolves the instance (the adapter accepts the instance or a factory) | ### Options [#options] ## The CSRF check [#the-csrf-check] Guards also run a CSRF check. A `POST`, `PUT`, `PATCH`, or `DELETE` authenticated by the session cookie, with no `Authorization` header, must carry an `Origin` of the app itself, the IAM origin, or one of `trustedOrigins`; a `Sec-Fetch-Site: same-origin` request also passes. Otherwise it is refused with `UNTRUSTED_ORIGIN`, or `CSRF_REJECTED` when there is no `Origin`. Why it matters: session cookies are `SameSite=Lax`, which browsers still send with form posts from sibling subdomains, so without the check a page on another subdomain could make changes as the signed-in person. Bearer tokens skip it because browsers never attach them on their own. Routes without a guard that change state should call `checkRequestOrigin` themselves (see [the core](#other-frameworks)). ## Per-request helpers [#per-request-helpers] Inside a route, `req.iam` (or `c.get('iam')`, `request.iam`) is how you ask about the current caller without passing headers around. The helpers are created once per request and memoize the session lookup. | Member | Returns / does | | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `getSession()` | The session, or `null` (memoized for the request) | | `requireSession({ stepUp? })` | The session, or throws `IamRequestError` (401, or 403 with a `reason`) | | `require(action, resource?, { tenantId? })` | Throws the server's refusal when not allowed | | `can(action, resource?, { tenantId? })` | An advisory boolean; checks made in the same tick share one `authorizeMany` call | | `authorize(checks, { tenantId? })` | `{ action, resource, allowed, reason }[]`; every check is denied (`UNAUTHENTICATED`) when signed out | | `listAccessible({ action, type, tenantId?, limit?, offset? })` | The managed resources the caller may act on | | `assertion({ tenantId, audience, ttlSeconds?, claims? })` | A signed assertion for a downstream service | | `client` | A typed client calling the IAM handler in process; its `Set-Cookie` answers go on this response | | `credential()` | `{ headers }` for `iam.api.*` calls, including cookies set earlier in the request | | `signOut()` | Ends the session and clears its cookie; never throws for an already-dead session | The resource defaults to the tenant (`iam/{tenantId}`), and the tenant to the session's. Cookies the in-process client receives override the incoming ones for later calls in the same request, so `getSession()` after a sign-in sees the new session. ### Sign in without client JavaScript [#sign-in-without-client-javascript] ```ts title="src/server.ts (Express)" import { safeRedirectPath } from 'better-iam/middleware'; app.post('/login', async (req, res) => { // The in-process client sets the session cookie on this response. const result = await req.iam.client.auth.signIn({ tenantId, // your organization's id, for example from client.tenants.lookup({ slug }) email: req.body.email, password: req.body.password, }); if ('mfaRequired' in result) return res.redirect(303, `/verify?challenge=${result.challenge}`); res.redirect(303, safeRedirectPath(String(req.query.next ?? ''))); }); app.post('/logout', async (req, res) => { await req.iam.signOut(); res.redirect(303, '/'); }); ``` A plain HTML form posts `application/x-www-form-urlencoded`, so `req.body` needs `app.use(express.urlencoded({ extended: false }))`, registered after the IAM middleware like `express.json()`. `safeRedirectPath` (from `better-iam/middleware`) accepts only same-site paths and falls back to `/`, so a `?next=` parameter cannot become an open redirect. A failed sign-in rejects with the server's error (for example `INVALID_CREDENTIALS` or `RATE_LIMITED`). Express 5 passes that rejection to `errorHandler`, which answers with the JSON envelope; on Express 4, catch it and call `next(error)`, or render your form again with the code. ## Other frameworks [#other-frameworks] The package root exports the framework-neutral pieces the adapters are built from: | Export | What it does | | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `createRequestHelpers(resolveIam, { url, headers, setCookie }, { basePath?, now? })` | Builds the per-request helpers over any request and response; `setCookie` appends one `Set-Cookie` header to your response | | `enforceGuard(helpers, request, spec, origin?)` | Runs a guard: session, step-up, then authorization, with the origin check first when `origin` is given. Resolves with the session or throws the refusal | | `refusalResponse(error, request, { loginPath?, stepUpPath? })` | Decides how to answer a refusal: `{ redirect }` for page navigations, else `{ status, body }` with the JSON envelope | | `checkRequestOrigin(request, trustedOrigins)` | The CSRF rule above; returns the refusal or `null` | | `checkStepUp(session, requirement, now?)` | The step-up rule; returns `null` or `{ code, reason, message, status }` | | `safeRedirectPath(value, fallback?)` | A same-site path, or the fallback | | `IamRequestError`, `isIamRefusal(error)`, `isAuthenticationError(error)` | The refusal class the helpers throw (`code`, `status`, `reason`), and tests for IAM refusals and for "no usable session" errors | | `errorBody(error)`, `errorCode(error)`, `errorStatus(error)` | The JSON envelope string for a refusal, and safe readers for an error's `code` and `status` | | `underPath(pathname, prefix)`, `withQuery(target, params)` | Prefix matching on path-segment boundaries (`/admin` covers `/admin/users`, not `/administrator`), and appending query parameters | | `nodeHeaders(record)`, `parseCookieHeader(header)`, `setCookieSummary(header)` | Convert Node header records to `Headers`, split a `Cookie` header, and read a `Set-Cookie` header's name, value, and whether it deletes the cookie | | `tenantOf(session)` | The tenant a session acts in | ```ts title="server.ts (a Web-standard fetch handler)" import { createRequestHelpers, enforceGuard, isIamRefusal, refusalResponse } from 'better-iam/middleware'; import { iam } from './iam.js'; export async function handle(request: Request): Promise { const url = new URL(request.url); if (url.pathname.startsWith('/api/iam/')) return iam.handler(request); const cookies: string[] = []; const helpers = createRequestHelpers(async () => iam, { url, headers: request.headers, setCookie: (header) => cookies.push(header), }); let response: Response; try { const session = await enforceGuard(helpers, request, { authorize: { action: 'projects:read' } }, { method: request.method, url, headers: request.headers, trustedOrigins: [iam.endpoint?.origin], }); response = Response.json({ projects: await listProjects(session) }); } catch (error) { if (!isIamRefusal(error)) throw error; const answer = refusalResponse(error, { url, headers: request.headers, method: request.method }, { loginPath: '/login' }); response = 'redirect' in answer ? new Response(null, { status: 303, headers: { location: answer.redirect } }) : new Response(answer.body, { status: answer.status, headers: { 'content-type': 'application/json' } }); } for (const cookie of cookies) response.headers.append('set-cookie', cookie); return response; } ``` [Protocol mounts](/docs/operations/deployment/protocol-mounts) covers serving OAuth, SAML, and SCIM endpoints beside the JSON API. ## Next steps [#next-steps] - [Typed client](/docs/frameworks/client): Call the API you mounted from the browser. - [Protocol mounts](/docs/operations/deployment/protocol-mounts): Serve OAuth, SAML, and SCIM next to the JSON API with `serve`. - [Policies](/docs/guides/authorization/policies): What `authorize` guards actually evaluate. # Nuxt (/docs/frameworks/nuxt) > The @better-iam/nuxt module mounts the IAM API in Nitro, renders sessions on the server, guards pages from page meta, and auto-imports helpers. `@better-iam/nuxt` is a module for Nuxt 3.14+ and 4. Add it to `nuxt.config.ts`, point it at your instance, and it: * **mounts the IAM HTTP API** in Nitro at `/api/iam/**`, and runs `iam.initialize()` before first use; * **installs the [Vue bindings](/docs/frameworks/vue)** with the session loaded during server rendering and the advisory decisions resolved before the HTML is sent; * **guards pages** from `definePageMeta({ iam })`, on the server and during client navigation; * **auto-imports** the composables, the `` component, and server utilities for your API routes, all typed from your instance. Why a module instead of wiring the pieces yourself? A Nuxt app renders the same pages on the server and in the browser, and calling the core API directly would leave you to: * **mount the API** in Nitro and initialize the instance before the first request; * **load the session on the server** without an HTTP round trip, because the HTTP API refuses cookie requests that carry no `Origin` (which is what a server-side call looks like); * **hydrate**: pass the session and any permission results to the browser, so its first render matches the HTML; * **guard both sides of navigation**, because after the first page load Nuxt navigates in the browser. The module does all of that from one line of configuration. One thing it leaves to you: the server utilities do not check where a request came from, so state-changing server routes that rely on the session cookie need their own `Origin` check ([Cross-site requests](#cross-site-requests)). Two related entries work without the module: `@better-iam/vue` holds the framework-level Vue bindings, and `@better-iam/nuxt/h3` holds the h3/Nitro server helpers. ## Install [#install] npm pnpm yarn bun ```bash npm i @better-iam/nuxt better-iam ``` ```bash pnpm add @better-iam/nuxt better-iam ``` ```bash yarn add @better-iam/nuxt better-iam ``` ```bash bun add @better-iam/nuxt better-iam ``` The module is not part of the umbrella package; install `@better-iam/nuxt` directly. The server instance can come from the umbrella (`better-iam`) or from `@better-iam/server` and an adapter. ## Setup [#setup] ### Create the instance [#create-the-instance] Export the `betterIam()` result as `iam` (or as the default export) from `server/iam.ts`. ```ts title="server/iam.ts" import { betterIam } from 'better-iam'; import { sqliteAdapter } from 'better-iam/adapter-sqlite'; export const iam = betterIam({ database: sqliteAdapter({ filename: '.data/iam.db' }), secret: process.env.BETTER_IAM_SECRET!, baseURL: process.env.BETTER_IAM_BASE_URL ?? 'http://localhost:3000', permissions: { actions: ['projects:read', 'projects:manage'] }, resolveResource: async (reference) => loadProjectOwnership(reference), }); ``` ### Add the module [#add-the-module] ```ts title="nuxt.config.ts" export default defineNuxtConfig({ modules: ['@better-iam/nuxt'], betterIam: { instance: '~~/server/iam', // default loginPath: '/login', }, }); ``` ### Declare page access [#declare-page-access] ```vue title="app/pages/account.vue" ``` ### Enforce in server routes [#enforce-in-server-routes] ```ts title="server/api/projects/[id].delete.ts" export default defineEventHandler(async (event) => { const { session } = await requireIamSession(event); // 401 without a session const id = getRouterParam(event, 'id')!; await requireIamAccess(event, { tenantId: session.tenantId, action: 'projects:manage', resource: { type: 'project', id }, }); // 401/403/429 with data.code = the IAM error code await deleteProject(id); return { ok: true }; }); ``` Page meta only decides what to render; server routes like this one are where operations are enforced. Because this route changes data on the strength of the session cookie, give it the `Origin` check from [Cross-site requests](#cross-site-requests) as well. ## Module options [#module-options] `loginPath`, `requireAuth`, `ssrSession`, and `nextParam` live in `runtimeConfig.public.betterIam`, so `NUXT_PUBLIC_BETTER_IAM_LOGIN_PATH` and similar variables override them at runtime. The module also writes a type template that registers your instance, so `useIamSession()` and the server utilities return its concrete session type, and `definePageMeta` accepts a typed `iam` key. ## Page access [#page-access] `definePageMeta({ iam })` declares who may open a page, so signed-out visitors are sent to sign in and people without the permission never see a half-rendered page. Use `true` for "any signed-in person", or an object that also names an action: ```vue title="app/pages/projects/[id]/settings.vue" ``` | `iam` value | Meaning | | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `true` | A session is required | | `false` | Opts the page out when `requireAuth` is on | | `{ action, resource?, tenantId?, redirectTo? }` | A session plus an allow decision. `resource` is an object or `(route) => ({ type, id })`; `tenantId` is a string or `(route, session) => tenantId` | The global route middleware runs during server rendering, where a redirect becomes a 302 and a denial a 403 response. It also runs during client navigation, where a denial renders the error page (a fatal `ACCESS_DENIED` error). It has to run in both places. After the first page load, Nuxt navigates in the browser without asking the server for HTML, so a check that ran only on the server would miss every later navigation. Signed-out visitors go to `loginPath` with `?next=` set to the full path. The middleware only decides what to render: server routes and the IAM API still enforce every operation. ## Composables and components [#composables-and-components] The module auto-imports the [Vue bindings](/docs/frameworks/vue) under `useIam*` names, typed from your instance, so pages and components read the session and ask for decisions without imports. ```vue title="app/pages/projects/[id].vue" ``` | Auto-import | From `@better-iam/vue` | Notes | | ------------------ | ---------------------- | ------------------------------------------------------------------- | | `useIamSession` | `useSession` | Typed from the registered instance: `session.identity.email` | | `useIamClient` | `useIamClient` | The typed browser client (the in-process session client during SSR) | | `useIamAuthorize` | `useAuthorize` | Batched checks; `allowed(action, resource?)` | | `useIamCan` | `useCan` | One decision as a `ComputedRef` | | `useIamAccessible` | `useAccessible` | The reverse query for managed resource types | | `` | `IamCan` | `default`, `fallback`, and `loading` slots | Inputs accept refs or getters and re-run when they change or when the signed-in identity changes. A session refresh for the same identity does not re-run them. The [Vue page](/docs/frameworks/vue#composables) describes each composable. ### Server rendering and hydration [#server-rendering-and-hydration] During SSR the plugin does not call the HTTP API, which refuses cookie requests without an `Origin`. Instead, a Nitro plugin binds an in-process session client to each request (`event.context.betterIam`). The session is read once per request and handed to the browser in the payload (`better-iam:session`). Decision and accessible-resource queries used by rendered components are awaited with `onServerPrefetch`, and their results travel in `better-iam:hydration`. The browser consumes each result once instead of refetching, so the first paint shows the right buttons with no hydration mismatch. After that, queries fetch normally. During SSR, `useIamClient()` returns the bound client, which has only `auth.getSession`, `authorizeMany`, and `listAccessible` (its `auth.signOut` throws: sign out from the browser). Call other API methods from server routes. ### Signing in [#signing-in] The client signs in through the mounted API, and the HTTP handler sets the session cookie. Then tell the store: ```ts title="app/pages/login.vue (script)" const route = useRoute(); const client = useIamClient(); const { setSession } = useIamSession(); const result = await client.auth.signIn({ tenantId, email, password }); if ('token' in result) { setSession(await client.auth.getSession()); await navigateTo(typeof route.query.next === 'string' ? route.query.next : '/'); } else { await navigateTo('/login/mfa'); // result.challenge → client.auth.verifyMfa(...) } ``` ## Server routes [#server-routes] Page guards only decide what to render, so every operation that changes data is enforced again in a server route. These utilities are auto-imported into Nitro server routes, middleware, and plugins: | Server auto-import | Purpose | | ---------------------------------------------------------- | --------------------------------------------------------------------------- | | `getIamSession(event)` | The session, or `null`; memoized per request | | `requireIamSession(event)` | The session, or a 401 h3 error | | `requireIamAccess(event, { tenantId, action, resource? })` | Enforce one action (the default resource is `iam/{tenantId}`) | | `iamCan(event, { tenantId, checks })` | Advisory decisions keyed `action@type/id`, all false for anonymous requests | | `issueIamAssertion(event, input)` | A short-lived signed assertion for a downstream service | | `iamCredential(event)` | `{ headers }` for direct `iam.api.*` calls | | `useIam()` | The initialized instance | ```ts title="server/api/me.get.ts" export default defineEventHandler(async (event) => { const { identity, session } = await requireIamSession(event); const access = await iamCan(event, { tenantId: session.tenantId, checks: [{ action: 'iam:identities:read' }], }); return { id: identity.id, email: identity.email, canReadMembers: access[`iam:identities:read@iam/${session.tenantId}`], }; }); ``` Errors are h3 errors with the IAM status. Their `statusMessage` and `data.code` carry the IAM code, so the JSON error body a client receives names it (`UNAUTHENTICATED`, `ACCESS_DENIED`, `RATE_LIMITED`, ...). For direct API calls, pass the caller's credential: `(await useIam()).api.identities.list(iamCredential(event), { tenantId })`. ### Cross-site requests [#cross-site-requests] Browsers attach the session cookie to requests on their own, including a form another site's page submits. The cookie is `SameSite=Lax` by default, which still lets a page on a sibling subdomain post to your app as the signed-in person. The mounted IAM API refuses such requests, but the server utilities above do not check where a request came from. For a server route that changes data and relies on the session cookie, check the `Origin` with `checkRequestOrigin` from `better-iam/middleware` (or `@better-iam/middleware`), the same rule the other integrations use: ```ts title="server/api/projects/[id].delete.ts" import { checkRequestOrigin } from 'better-iam/middleware'; import { eventHeaders } from '@better-iam/nuxt/h3'; export default defineEventHandler(async (event) => { // Cookie requests other than GET, HEAD, and OPTIONS must come from this app's own origin. const refusal = checkRequestOrigin({ method: event.method, url: getRequestURL(event), headers: eventHeaders(event), }); if (refusal) throw createError({ statusCode: refusal.status, statusMessage: refusal.code, data: { code: refusal.code } }); const { session } = await requireIamSession(event); // ... }); ``` It refuses a cookie-authenticated request with `CSRF_REJECTED` when it has no `Origin` and `UNTRUSTED_ORIGIN` when the origin is foreign. Requests that carry an `Authorization` header or `Sec-Fetch-Site: same-origin` pass. Pass extra allowed origins, such as a separate front end, as the second argument. ## Without Nuxt [#without-nuxt] Any h3 v1/v2 or Nitro app can use the server helpers directly: ```ts title="server.ts" import { createApp, createError, defineEventHandler, toWebRequest } from 'h3'; import { createIamH3 } from '@better-iam/nuxt/h3'; import { iam } from './iam.js'; const iamH3 = createIamH3(iam, { toRequest: toWebRequest, createError }); const app = createApp(); app.use( '/api/iam', defineEventHandler((event) => iamH3.handler(event)), ); app.use( '/me', defineEventHandler(async (event) => (await iamH3.requireSession(event)).identity), ); ``` `createIamH3(iam, options)` accepts the instance or a factory and returns: | Member | What it does | | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `handler(event)` | Passes the request to `iam.handler` and returns its Web `Response`, which h3 sends as is; mount it at `/api/iam/**` | | `getSession(event)` | The session, or `null`; memoized on the event | | `requireSession(event)` | The session, or a 401 error with `data.code` `UNAUTHENTICATED` | | `require(event, { tenantId, action, resource? })` | Enforces one action; a refusal throws an error with the IAM status (401, 403, 429) and code | | `can(event, { tenantId, checks })` | Advisory decisions keyed `action@type/id`, all false for anonymous requests | | `assertion(event, input)` | A short-lived signed assertion about the caller for a downstream service | | `credential(event)` | `{ headers }` for direct `iam.api.*` calls | | `bind(event)` | A session client (`auth.getSession`, `authorizeMany`, `listAccessible`) bound to the event, for server rendering; the Nuxt plugin uses it | | `resolve()` | The instance | | Option | Meaning | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `toRequest` | Converts an event into a Web `Request`; pass h3's `toWebRequest`. Without it, the helper reads h3 v2 `event.req`, h3 v1 `event.web.request`, or the Node request stream | | `createError` | Builds the errors `requireSession` and `require` throw; pass h3's `createError`. Without it, they throw `IamH3Error`, which h3 treats as one of its own errors in both majors (`statusCode`, `status`, `statusMessage`, `data.code`) | The entry also exports three helpers: * `eventHeaders(event)` turns an h3 v1 or v2 event into Web `Headers`. * `eventRequest(event)` turns it into a Web `Request`. The Node fallback buffers the body, so call it before anything else reads the body. * `isAuthenticationError(error)` recognizes the errors `getSession` treats as signed out. Any Vue 3.3+ app can use the bindings directly: `app.use(createIam({ client: createIamClient() }))`. For custom SSR, pass `server: true`, an in-process client, and `createHydration()` on the server; see [Vue](/docs/frameworks/vue#server-rendering-and-hydration). ## Next steps [#next-steps] - [Vue](/docs/frameworks/vue): Every composable the module auto-imports, in detail. - [Sign-in methods](/docs/guides/authentication/sign-in-methods): Passwords, MFA, passkeys, and federated sign-in for your login page. - [Policies](/docs/guides/authorization/policies): What `requireIamAccess` and page meta actually evaluate. # React Router (/docs/frameworks/react-router) > Root middleware, guarded loaders and actions, and an API resource route for React Router framework mode (v7.9+ and v8), with React hooks in the browser. `@better-iam/react-router` integrates Better IAM with React Router framework mode, v7.9+ and v8. Loaders and actions run on the server, so that is where sessions are read and access is enforced. Calling the core API directly from them would leave you to read cookies from every request and map refusals to redirects or error responses. You would also check the `Origin` of form posts and copy the cookies a sign-in issues onto the response by hand. This package does that for you: * **A root middleware** creates per-request helpers (the session, memoized for the request) and adds the cookies the in-process client receives to the response, including on redirects. * **A resource route** serves the IAM HTTP API for the browser client. * **`guard`** wraps loaders (login and step-up redirects, a 403 for the error boundary) and **`action`** wraps actions (refusals returned for `useActionData`). * **Origin checks.** `guard` and `action` refuse cookie-authenticated requests from other origins before your code runs ([why](#loaders-and-actions)). * **Hydration.** `sessionData` gives the root loader the session for ``, so the first browser render matches the server's HTML without a request. It is built on [`@better-iam/middleware`](/docs/frameworks/node), so the helpers are the same as `req.iam` in Express. Browser components use [`@better-iam/react`](/docs/frameworks/react) (`IamProvider`, `useSession`, `useAuthorize`, `Can`). ## Install [#install] npm pnpm yarn bun ```bash npm i better-iam ``` ```bash pnpm add better-iam ``` ```bash yarn add better-iam ``` ```bash bun add better-iam ``` The umbrella exposes this package as `better-iam/react-router`. Or install `@better-iam/react-router`, `@better-iam/react`, and `@better-iam/client` next to `@better-iam/server` and a storage adapter. > **Route middleware on React Router 7.** React Router 7 ships route middleware behind the `future.v8_middleware` flag in `react-router.config.ts`; turn it on before adding `iamRouter.middleware`. React Router 8 always enables middleware. ## Setup [#setup] ### Create the instance and the router helpers [#create-the-instance-and-the-router-helpers] Keep this file server-only: its name ends in `.server.ts`, so the client bundle never contains server code. ```ts title="app/iam.server.ts" import { betterIam } from 'better-iam'; import { sqliteAdapter } from 'better-iam/adapter-sqlite'; import { createIamRouter } from 'better-iam/react-router'; export const iam = betterIam({ database: sqliteAdapter({ filename: '.data/iam.db' }), secret: process.env.BETTER_IAM_SECRET!, baseURL: process.env.BETTER_IAM_BASE_URL ?? 'http://localhost:5173', permissions: { actions: ['projects:read', 'projects:manage'] }, resolveResource: async (reference) => loadProjectOwnership(reference), }); export const iamRouter = createIamRouter(iam, { loginPath: '/login', stepUpPath: '/verify' }); export const ready = iam.initialize(); ``` ### Add the middleware, the session loader, and the provider [#add-the-middleware-the-session-loader-and-the-provider] ```tsx title="app/root.tsx" import { useEffect, useState } from 'react'; import { Outlet } from 'react-router'; import { createIamClient, type IamClient } from 'better-iam/client'; import { IamProvider, useSession } from 'better-iam/react'; import type { Route } from './+types/root'; import { iamRouter, ready, type iam } from './iam.server'; type Client = IamClient; export const middleware: Route.MiddlewareFunction[] = [ async (_args, next) => { await ready; // the first request waits for migrations return next(); }, iamRouter.middleware, ]; export async function loader(args: Route.LoaderArgs) { return { ...(await iamRouter.sessionData(args)), origin: new URL(args.request.url).origin }; } export default function App({ loaderData }: Route.ComponentProps) { const [client] = useState(() => createIamClient({ baseURL: loaderData.origin })); return ( ); } // Actions (sign in, sign out) revalidate the root loader; keep the store on the server's answer. function SessionSync({ session }: { session: Route.ComponentProps['loaderData']['session'] }) { const { setSession } = useSession(); useEffect(() => setSession(session), [session, setSession]); return null; } ``` `initialSession` lets the first render show the signed-in person without a request from the browser, and server-rendered markup matches what the browser renders. ### Serve the IAM API [#serve-the-iam-api] ```ts title="app/routes.ts" import { type RouteConfig, index, route } from '@react-router/dev/routes'; export default [ index('routes/home.tsx'), route('login', 'routes/login.tsx'), // The IAM HTTP API (sign-in, sessions, and every API group the client calls). route('api/iam/*', 'routes/api.iam.ts'), ] satisfies RouteConfig; ``` ```ts title="app/routes/api.iam.ts" import { iamRouter } from '../iam.server'; export const loader = iamRouter.api; // GET: /health, /metrics export const action = iamRouter.api; // POST: every API method ``` ## What `createIamRouter` returns [#what-createiamrouter-returns] `createIamRouter(iam, options)` accepts the instance or a factory that returns it, and gives back everything the routes need: | Member | What it does | Use it | | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | `middleware` | Creates the per-request helpers in the router context and appends issued `Set-Cookie` headers to the response | On the root route, once | | `api` | Passes the request to `iam.handler` | As the loader and action of the `api/iam/*` resource route | | `guard(loader, spec)` | Runs `loader(args, session)` only for a signed-in, stepped-up, allowed visitor | For loaders of protected routes | | `action(fn, spec)` | Runs `fn(args, session)` after the origin, session, step-up, and authorization checks; returns refusals as `data()` | For actions that change state | | `helpers(args)` | The per-request helpers (below) | In any loader or action | | `requireSession(args, { stepUp?, loginRedirect?, returnTo? })` | The session, or a login or step-up redirect | In hand-written loaders | | `require(args, action, resource?, { tenantId?, deniedRedirect? })` | Enforces one action; signed out redirects to login, denied throws a 403 `data()` or redirects | In hand-written loaders | | `sessionData(args)` | `{ session }` for `` | In the root loader | | `context`, `resolve` | The router context key holding the helpers, and the instance resolver | Advanced wiring | The package also re-exports `safeRedirectPath`, `checkStepUp`, `checkRequestOrigin`, and `isAuthenticationError` from the middleware core. ### Options [#options] ## Loaders and actions [#loaders-and-actions] Wrap a loader with `guard` when the page must not render for the wrong person, and an action with `action` when the form should show the refusal instead of an error page. ```tsx title="app/routes/projects.$id.tsx" import { iamRouter } from '../iam.server'; import type { Route } from './+types/projects.$id'; export const loader = iamRouter.guard( async (args: Route.LoaderArgs, session) => ({ project: await loadProject(args.params.id), canManage: await iamRouter.helpers(args).can('projects:manage', { type: 'project', id: args.params.id }), }), { authorize: { action: 'projects:read', resource: (args) => ({ type: 'project', id: args.params.id }), }, }, ); export const action = iamRouter.action( async (args: Route.ActionArgs, session) => renameProject(args.params.id, await args.request.formData()), { authorize: { action: 'projects:manage', resource: (args) => ({ type: 'project', id: args.params.id }), }, }, ); ``` **`guard(loader, { stepUp?, authorize?, loginRedirect?, deniedRedirect? })`** * Signed-out visitors are redirected to `loginPath?next=…`. Document and `.data` requests both work, and `next` never includes `.data`. * Sessions that must step up are redirected to `stepUpPath?next=…&reason=…`. * A denial throws `data({ code, message }, { status: 403 })` for the route's `ErrorBoundary` (`isRouteErrorResponse(error)`, `error.data.code`), or redirects to `deniedRedirect`. * `authorize` callbacks receive `(args, session)`; the resource defaults to the tenant (`iam/{tenantId}`) and the tenant to the session's. * It runs the same origin check as `action` first. Loaders usually answer `GET`, which the check lets through. **`action(fn, { stepUp?, authorize? })`** * First refuses cookie-authenticated requests from untrusted origins (`UNTRUSTED_ORIGIN`, or `CSRF_REJECTED` without an `Origin`). Session cookies are `SameSite=Lax`, which still sends them with posts from sibling subdomains, so a page on another subdomain could otherwise act as the signed-in person. * Returns every IAM refusal as `data({ code, message }, { status })` for `useActionData`: 401, 403, 400 for invalid input, 429 when rate limited. Redirects and other errors propagate. * React Router v8 also refuses cross-origin document action posts on its own (400), so the origin check is a second layer that also covers `.data` requests. ```tsx title="app/routes/account.tsx" export default function Account({ loaderData, actionData }: Route.ComponentProps) { return ( {actionData && 'code' in actionData ?

{actionData.code}

: null} ); } ``` ```tsx title="app/root.tsx (error boundary)" import { isRouteErrorResponse } from 'react-router'; export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { if (isRouteErrorResponse(error)) return (

{error.status}

{(error.data as { code?: string } | undefined)?.code ?? error.statusText}

); return

Something went wrong

; } ``` ## Per-request helpers [#per-request-helpers] `iamRouter.helpers(args)` returns the same helpers as the [Node adapters](/docs/frameworks/node#per-request-helpers): | Member | What it does | | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `getSession()` | The session, or `null`; memoized for the request | | `requireSession({ stepUp? })` / `require(action, resource?, { tenantId? })` | Throw the refusal instead of redirecting; prefer `iamRouter.requireSession(args)` and `iamRouter.require(args, ...)` in loaders | | `can(action, resource?, { tenantId? })` | An advisory boolean; checks made in the same tick share one `authorizeMany` call | | `authorize(checks, { tenantId? })` | `{ action, resource, allowed, reason }[]` for several checks | | `listAccessible({ action, type, ... })` | The managed resources the caller may act on | | `assertion({ tenantId, audience, ... })` | A signed assertion for a downstream service | | `credential()` | `{ headers }` for direct `iam.api.*` calls, including cookies set earlier in the request | | `client` | The typed client calling the IAM handler in process | | `signOut()` | Ends the session and clears its cookie | `client` is what makes sign-in work without browser JavaScript. `helpers(args).client.auth.signIn(...)` in an action, followed by `throw redirect(next)`, signs the person in; the middleware puts the session cookie on the redirect. **Sign in:** ```tsx title="app/routes/login.tsx" import { Form, data, redirect } from 'react-router'; import { safeRedirectPath } from 'better-iam/react-router'; import { iamRouter } from '../iam.server'; import type { Route } from './+types/login'; export async function action(args: Route.ActionArgs) { const form = await args.request.formData(); const email = String(form.get('email') ?? ''); let result; try { result = await iamRouter.helpers(args).client.auth.signIn({ tenantId: String(form.get('tenantId') ?? ''), email, password: String(form.get('password') ?? ''), }); } catch (error) { return data({ email, message: error instanceof Error ? error.message : 'Sign-in failed' }, { status: 400 }); } if (!('token' in result)) throw redirect(`/verify?challenge=${result.challenge}`); throw redirect(safeRedirectPath(new URL(args.request.url).searchParams.get('next'))); } ``` **Sign out:** ```ts title="app/routes/logout.tsx" import { redirect } from 'react-router'; import { iamRouter } from '../iam.server'; import type { Route } from './+types/logout'; export async function action(args: Route.ActionArgs) { await iamRouter.helpers(args).signOut(); throw redirect('/'); } ``` **Admin calls:** ```ts title="app/routes/admin.tsx" import { iam, iamRouter } from '../iam.server'; import type { Route } from './+types/admin'; // Only people with iam:identities:read on their tenant get past the guard; others see the 403 error boundary. export const loader = iamRouter.guard( async (args: Route.LoaderArgs, session) => { const identities = await iam.api.identities.list(iamRouter.helpers(args).credential(), { tenantId: session.session.tenantId, limit: 50, }); return { members: identities.map((identity) => ({ id: identity.id, email: identity.email })) }; }, { authorize: { action: 'iam:identities:read' } }, ); ``` `safeRedirectPath` accepts only same-site paths and falls back to `/` for anything else, so a `?next=` parameter cannot send people to another site after they sign in. ## Build notes [#build-notes] * **Native modules.** Keep the server packages out of the SSR bundle with `ssr: { external: ['better-iam'] }` (or `@better-iam/server` plus the adapter). * **One React Router.** In a monorepo, also add `resolve: { dedupe: ['react-router', 'react', 'react-dom'] }` so the app and the integration share one React Router instead of each resolving its own copy. * **Server-only code.** Keep `iam.server.ts` server-only; the example's client bundle contains no server code. ```ts title="vite.config.ts" import { reactRouter } from '@react-router/dev/vite'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [reactRouter()], resolve: { dedupe: ['react-router', 'react', 'react-dom'] }, ssr: { external: ['@better-iam/server', '@better-iam/adapter-sqlite'] }, }); ``` ## Next steps [#next-steps] - [React](/docs/frameworks/react): `IamProvider`, `useSession`, `useAuthorize`, and `Can` for your components. - [Express, Hono, Fastify](/docs/frameworks/node): The shared request helpers and the CSRF rule behind this package. - [Policies](/docs/guides/authorization/policies): What `authorize` rules actually evaluate. # React (/docs/frameworks/react) > IamProvider, useSession, useAuthorize, Can, and self-service hooks for rendering React UI from the signed-in session and advisory decisions. `@better-iam/react` connects React components to the [typed client](/docs/frameworks/client). Calling the client from components works, but every component would then fetch the session on its own, keep its own loading state, and miss a sign-out that happened elsewhere on the page. This package keeps one session for the whole tree, re-renders every component that depends on it when it changes, batches permission checks, and re-runs them when a different person signs in. It runs in the browser, so it splits the work with your server framework: * **Sessions on the server and guards.** The hooks never enforce anything. Guard pages and mutations with a server integration such as [Next.js](/docs/frameworks/nextjs) or [React Router](/docs/frameworks/react-router). * **Cross-site checks.** The client sends the `X-Better-IAM` header the IAM API requires; your own routes are checked by the server integration. * **Hydration.** Pass the session your server loaded as `initialSession`, and the first browser render matches the server's HTML without a request ([why it matters](#iamprovider)). | Export | What it does | Use it to | | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | [`IamProvider`](#iamprovider) | Holds the client and one session store for the tree; loads the session on mount or starts from a server-rendered one | Wrap the app once | | [`useSession()`](#usesession) | The current session and its status, plus `refresh`, `signOut`, and `setSession` | Show who is signed in, sign out, apply a sign-in | | `useIamClient()` | The client passed to the provider | Call any API method from a component | | [`useAuthorize()`](#authorization-hooks) | Several advisory decisions in one `authorizeMany` call, with `allowed(action, resource?)` | Decide which menu items and buttons to render | | [`Can`](#authorization-hooks) | Renders its children only when one action is allowed, with `fallback` and `loading` | Gate one element on one permission | | [`useAccessible()`](#authorization-hooks) | The managed resources of a type the person may act on | Render a list of what someone can open | | [`useAgreements()`](#useagreements) | The person's terms of use, the pending ones, and `accept` | Block the app until required terms are accepted | | [`useAccessPaths()`](#useaccesspaths) | Whether an action is allowed and, if not, what the person can do about it | Build a "request access" screen | | `createSessionStore`, `isUnauthenticated` | The framework-agnostic session store and its error test | Use the store outside React | It requires React 18.2 or later. Everything it returns is advisory UI state: it decides what to render, and the server still enforces every operation. Next.js applications use [`IamNextProvider`](/docs/frameworks/nextjs/advanced#keeping-server-components-in-sync), which wraps `IamProvider`, and [React Router](/docs/frameworks/react-router) applications use this package for their components. ## Install [#install] npm pnpm yarn bun ```bash npm i @better-iam/react @better-iam/client ``` ```bash pnpm add @better-iam/react @better-iam/client ``` ```bash yarn add @better-iam/react @better-iam/client ``` ```bash bun add @better-iam/react @better-iam/client ``` With the umbrella package, import from `better-iam/react` and `better-iam/client`. ## Setup [#setup] ### Create the client once [#create-the-client-once] Create the client outside render (at module scope, or in `useState` with an initializer). The provider creates its session store from the first client it receives, so a new client on every render would leave the hooks and the store talking to different objects. ```ts title="src/iam-client.ts" import { createIamClient } from '@better-iam/client'; import type { iam } from '../server/iam.js'; export const client = createIamClient({ baseURL: 'https://app.example.com' }); ``` ### Wrap the app in `IamProvider` [#wrap-the-app-in-iamprovider] The provider holds the session for everything rendered inside it. Without `initialSession`, it loads the session once on mount. ```tsx title="src/app.tsx" import { IamProvider } from '@better-iam/react'; import { client } from './iam-client'; export function App() { return ( ); } ``` ### Read the session and decisions [#read-the-session-and-decisions] `useSession` returns the signed-in person, `useAuthorize` asks for this screen's permissions in one call, `useAccessible` lists the projects the person may read, and `Can` shows one link only when it is allowed. ```tsx title="src/workspace.tsx" import { Can, useAccessible, useAuthorize, useSession } from '@better-iam/react'; import type { client } from './iam-client'; export function Workspace({ tenantId }: { tenantId: string }) { const { status, session, signOut } = useSession(); const { allowed } = useAuthorize({ tenantId, checks: [{ action: 'projects:manage', resource: { type: 'project', id: 'website' } }], }); const { resources } = useAccessible({ tenantId, action: 'projects:read', type: 'project' }); if (status === 'loading') return

Loading…

; if (status !== 'authenticated') return Sign in; return ( <>

{session.identity.name}

    {resources.map((project) => (
  • {project.resourceId}
  • ))}
{allowed('projects:manage', { type: 'project', id: 'website' }) && } Invite a member ); } ``` Hooks rendered outside `IamProvider` throw `Better IAM hooks must be rendered inside `. ## IamProvider [#iamprovider] Why `initialSession` matters: without it, the provider starts in `loading` and fetches the session after the page appears, so a server-rendered page first shows a "loading" or signed-out state and then changes. With it, the first render already knows who is signed in, makes no request, and matches the markup the server produced, so React hydrates without a mismatch. Server-rendering frameworks pass the session their server loaded: `iamNext.sessionForClient()` in Next.js, `iamRouter.sessionData(args)` in React Router. Focus refresh exists because sessions end outside the page: they expire, or are revoked from another device or by an administrator. Reloading when the tab becomes visible again brings the UI back in line with the server. ## useSession [#usesession] `useSession()` returns the session snapshot, typed from the client, plus actions. | Member | Value | | --------------------- | --------------------------------------------------------------------------------------- | | `status` | `loading`, `authenticated`, `unauthenticated`, or `error` | | `session` | `{ identity, session, ... }` from `auth.getSession`, or `null` | | `error` | The last error; unauthenticated states keep the server's error so its code can be shown | | `isAuthenticated` | `status === 'authenticated'` | | `refresh()` | Reloads the session; concurrent calls share one request | | `signOut()` | Signs out on the server, then clears the local session even if that call failed | | `setSession(session)` | Replaces the local session, for example right after a sign-in response | After signing in through the client, hand the new session to the store so every hook updates at once: ```tsx import { useIamClient, useSession } from '@better-iam/react'; import type { client as appClient } from './iam-client'; export function useSignIn(tenantId: string) { const client = useIamClient(); const { setSession } = useSession(); return async (email: string, password: string) => { const result = await client.auth.signIn({ tenantId, email, password }); if ('token' in result) setSession(await client.auth.getSession()); else startMfa(result.challenge); // see the typed client's sign-in flows }; } ``` `useIamClient()` returns the client passed to the provider, typed as you declare it. ## Authorization hooks [#authorization-hooks] `useAuthorize({ tenantId, checks, enabled? })` sends all its checks in one `authorizeMany` call, so a toolbar with ten permission-dependent buttons costs one request instead of ten. It re-runs when the checks or the signed-in identity change (a different person must never see the previous person's buttons), and a session refresh for the same identity does not re-run it. | Member | Value | | ---------------------------- | --------------------------------------------------------------------------------------------------- | | `status` | `idle`, `loading`, `ready`, or `error` | | `results` | `{ action, resource, allowed, reason }[]` | | `error` | The transport or server error, if the call failed | | `allowed(action, resource?)` | The decision for one check; `false` until results arrive. The resource defaults to `iam/{tenantId}` | | `refresh()` | Runs the checks again | When the session is `unauthenticated`, every check resolves to denied with `reason: 'UNAUTHENTICATED'` without a request. `enabled: false` holds the query. `Can` wraps one check for rendering: ```tsx Read only} loading={} > ``` It renders `loading` while the decision is pending, `children` when allowed, and `fallback` otherwise. Without a `resource`, it checks the tenant itself. `useAccessible({ tenantId, action, type, limit?, offset?, enabled? })` wraps the reverse query ([how it works](/docs/guides/authorization/queries)) for managed resource types. It returns `status`, `resources` (each with `id`, `type`, `resourceId`, `attributes`, and optional `ownerId`, `parentType`, `parentId`), `total`, `error`, and `refresh()`. Signed out, it settles to an empty list. ## Self-service hooks [#self-service-hooks] These two hooks let people resolve their own access problems without waiting for an administrator: accepting terms that policies require, and finding out what would let them perform an action they were denied. ### useAgreements [#useagreements] `useAgreements({ tenantId, enabled? })` lists the signed-in person's terms of use, or agreements (`agreements.listMine`). Each agreement has `id`, `name`, `content`, `url?`, `version`, `required`, `accepted`, `acceptedAt?`, and `acceptedVersion?`. `pending` holds the required agreements not accepted in their current version: render their text and an accept button before the rest of the app when policies hold back access until acceptance. `accept(agreement)` records acceptance of the version the person was shown, then reloads. ```tsx const { pending, accept } = useAgreements({ tenantId }); if (pending.length) return (

{pending[0].name}

{pending[0].content}
); ``` See [Agreements](/docs/guides/governance/agreements) for how policies require them. ### useAccessPaths [#useaccesspaths] `useAccessPaths({ tenantId, action, resource, enabled? })` answers "how do I get access?" for an action the person may be denied (`accessPaths.find`). It returns `allowed`, `reason`, and, when denied, the self-service `paths` the server verified would allow the person: | `kind` | What the person can do | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mfa` | Sign in again with a second factor | | `accept-agreements` | Accept the listed `agreements` (`id`, `name`, `version`) | | `activate` | Activate an eligible role: `bindingId`, `role` (`id`, `name`), `requireApproval`, `requireJustification`, `requireMfa`, `maxActivationMs?` | | `request-package` | Request an access package: `package` (`id`, `name`, `description?`) and `requireJustification` | An empty `paths` list means the person has to ask an administrator. Call `refresh()` after they take a path. [Access paths](/docs/guides/governance/access-paths) explains how the server finds them, and [Just-in-time elevation](/docs/guides/privileged-access/elevation) covers activation. ## The session store [#the-session-store] The provider keeps its session in a framework-agnostic store that it creates for you and does not expose. Create your own with `createSessionStore` when code outside React, such as a plain script, needs session state. `createSessionStore` and `isUnauthenticated` are re-exported from [`@better-iam/client/session`](/docs/frameworks/client#session-store), with the `SessionClient`, `SessionOf`, `SessionSnapshot`, `SessionStatus`, and `SessionStore` types. The hooks read the provider's store through `useSyncExternalStore`, and you can subscribe to a store of your own the same way. ## Next steps [#next-steps] - [Next.js](/docs/frameworks/nextjs): Server guards and `IamNextProvider`, which wraps `IamProvider` for the App Router. - [React Router](/docs/frameworks/react-router): Guarded loaders and actions, with these hooks in the browser. - [Typed client](/docs/frameworks/client): Every API method, error codes, and passkey helpers. # SvelteKit (/docs/frameworks/sveltekit) > A handle hook that serves the IAM API and guards sections, locals.iam for loads and form actions, and Svelte 4 and 5 stores for the browser. `@better-iam/svelte` has two entry points: * **`@better-iam/svelte/kit`** is the server side. A `handle` hook serves the IAM HTTP API, attaches per-request helpers to `event.locals.iam`, and enforces path rules. `guard` wraps server loads and `action` wraps form actions. * **`@better-iam/svelte`** is the browser side: Svelte stores for the session and advisory decisions, for Svelte 4 and 5 (runes or not). Server loads can hand their results to the stores, so the first render needs no extra requests. Why use it instead of calling the core from your loads? Every load and action would otherwise read the session from cookies, redirect signed-out visitors with a safe `?next=`, turn refusals into `error()` or `fail()`, and copy the cookies a sign-in issues into `event.cookies`. A whole section such as `/admin` would need the same check repeated in every load. The kit does these once: * **Sessions on the server.** `locals.iam` gives every load the same memoized helpers: `getSession()` reads the cookie once per request, and cookies a sign-in issues land in `event.cookies`, so later loads in the same request see the new session. * **Guards.** `handle` protects whole sections by path, and `guard` and `action` protect single routes. Each requires a session, then any step-up, then the permission. * **Cross-site checks.** Form posts are checked by SvelteKit's own origin check (`csrf.checkOrigin`, on by default), so the kit adds none of its own. Keep that option on. * **Hydration.** `sessionData` and `locals.iam.authorize` results seed the browser stores, so the first render matches the server's HTML without extra requests. The umbrella package exposes the entries as `better-iam/svelte/kit` and `better-iam/svelte`. The per-request helpers are built on the same core as the [Express, Hono, and Fastify adapters](/docs/frameworks/node). | Export | Entry | What it does | | ---------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------- | | `createIamKit(iam, options)` | `/kit` | Creates the kit: `handle`, `guard`, `action`, `locals`, `sessionData`, and `resolve` (below) | | `IamLocals` | `/kit` | The type of `event.locals.iam`, for `app.d.ts` | | `safeRedirectPath(value, fallback?)` | `/kit` | A same-site path for `?next=`, or the fallback | | `checkStepUp(session, requirement, now?)` | `/kit` | The step-up rule the guards use; `null` or the failure | | `isAuthenticationError(error)`, `isKitControlError(error)` | `/kit` | Test for "no usable session" errors, and for SvelteKit's own `redirect()` / `error()` throws that must propagate | | `parseSetCookie(header)` | `/kit` | Parses a `Set-Cookie` header into the arguments `event.cookies.set` takes | | `createIam({ client, initialSession?, ... })` | root | The browser side: one session store and the advisory stores | | `setIamContext(iam)`, `getIamContext()` | root | Share one `createIam` result with descendant components | | `createSessionStore`, `isUnauthenticated` | root | The framework-agnostic session store and its error test | | Kit member | What it does | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `handle` | The server hook: serves the IAM API under `basePath`, attaches `event.locals.iam`, and enforces `protect` rules before any load, action, or endpoint runs | | `guard(load, spec)` | Wraps a server load (or a `+server.ts` handler): runs it with the session once the visitor is signed in, stepped up, and allowed | | `action(fn, spec)` | Wraps a form action: the same checks, with every IAM refusal returned as `fail(status, { code, message })` | | `locals(event)` | The per-request helpers for any request event; `handle` stores them on `event.locals.iam` | | `sessionData(event)` | `{ session }` for the root layout load, to seed the browser stores | | `resolve()` | The instance (the kit accepts the instance or a lazy factory) | ## Install [#install] npm pnpm yarn bun ```bash npm i better-iam ``` ```bash pnpm add better-iam ``` ```bash yarn add better-iam ``` ```bash bun add better-iam ``` Or install `@better-iam/svelte` and `@better-iam/client` next to `@better-iam/server` and a storage adapter. It supports SvelteKit 2 with Svelte 4 or 5. ## Setup [#setup] ### Create the instance and the kit [#create-the-instance-and-the-kit] ```ts title="src/lib/server/iam.ts" import { betterIam } from 'better-iam'; import { sqliteAdapter } from 'better-iam/adapter-sqlite'; import { createIamKit } from 'better-iam/svelte/kit'; export const iam = betterIam({ database: sqliteAdapter({ filename: '.data/iam.db' }), secret: process.env.BETTER_IAM_SECRET!, baseURL: process.env.BETTER_IAM_BASE_URL ?? 'http://localhost:5173', permissions: { actions: ['projects:read', 'projects:manage'] }, resolveResource: async (reference) => loadProjectOwnership(reference), }); export const iamKit = createIamKit(iam, { protect: [ { path: '/app' }, // any session { path: '/admin', authorize: { action: 'iam:identities:read' } }, { path: '/billing', stepUp: { mfa: true } }, ], stepUpPath: '/verify', }); ``` ### Install the hook [#install-the-hook] ```ts title="src/hooks.server.ts" import { iam, iamKit } from '$lib/server/iam'; export const init = () => iam.initialize(); export const handle = iamKit.handle; // or sequence(iamKit.handle, yourHandle) ``` ### Type `locals` [#type-locals] ```ts title="src/app.d.ts" import type { IamLocals } from 'better-iam/svelte/kit'; import type { iam } from '$lib/server/iam'; declare global { namespace App { interface Locals { iam: IamLocals; } interface Error { message: string; code?: string; // IAM refusals carry their code } } } export {}; ``` ### Hand the session to the browser [#hand-the-session-to-the-browser] The root layout load returns the session (and any decisions the first render needs); the layout component creates the stores from them. See [Browser stores](#browser-stores). ## Kit options [#kit-options] `createIamKit(iam, options)` takes the options below. Most apps set only `protect`, `loginPath`, and `stepUpPath`. A `protect` rule's `path` can be a prefix (`/admin` covers `/admin/users`, not `/administrator`), a regular expression, or a `(url) => boolean`. `authorize` takes `{ action, resource?, tenantId? }`, where `resource` and `tenantId` are functions of `{ event, session }`; the resource defaults to the tenant (`iam/{tenantId}`) and the tenant to the session's. `stepUp` (step-up authentication) takes `{ mfa?, maxAgeMs?, redirectTo? }`. With `deniedRedirect`, a refused visitor is redirected instead of shown a 403. > **Pages without a server load.** SvelteKit renders a page that has no `+page.server.ts` or `+layout.server.ts` load entirely in the browser during client-side navigation, so the request never reaches `handle` and its `protect` rules. Give every protected section a server load, which is where its data comes from anyway (`iamKit.guard` is a good fit). Full page loads, data requests (`__data.json`), form actions, and `+server.ts` endpoints always pass through `handle`, and redirects thrown there reach client-side navigations as SvelteKit redirects. ## Server loads, actions, and endpoints [#server-loads-actions-and-endpoints] `event.locals.iam` is created once per request. Sessions and decisions are memoized, and checks made in the same tick share one `authorizeMany` call. Cookies the in-process client receives are written to `event.cookies`, and later calls in the same request see them. | Member | Returns / does | | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | | `getSession()` | The session, or `null` | | `requireSession({ stepUp?, loginRedirect?, returnTo? })` | The session, or a 303 to the login or step-up page (a 403 error when no step-up page is set) | | `require(action, resource?, { tenantId?, deniedRedirect? })` | Signed out: login redirect. Denied: `deniedRedirect`, or `error(403, { code })` | | `can(action, resource?, { tenantId? })` | An advisory boolean (false when signed out) | | `authorize(checks, { tenantId? })` | `{ action, resource, allowed, reason }[]`, the shape the browser stores take as `initial` | | `listAccessible({ action, type, ... })` | The managed resources the caller may act on | | `assertion({ tenantId, audience, ... })` | A signed assertion for a downstream service | | `client` | A typed client calling `iam.handler` in process; `Set-Cookie` lands in `event.cookies` | | `credential()` | `{ headers }` for direct `iam.api.*` calls, including cookies set earlier in the request | | `signOut()` | Ends the session and clears the cookie | `iamKit.locals(event)` builds the same helpers for any request event, and `iamKit.sessionData(event)` returns `{ session }` for the root layout load. ```ts title="src/routes/projects/[id]/+page.server.ts" import { iamKit } from '$lib/server/iam'; export const load = iamKit.guard( async (event, session) => ({ project: await loadProject(event.params.id), canManage: await event.locals.iam.can('projects:manage', { type: 'project', id: event.params.id, }), }), { authorize: { action: 'projects:read', resource: ({ event }) => ({ type: 'project', id: event.params.id }), }, }, ); export const actions = { // IAM refusals come back as fail(status, { code, message }); redirects and other errors propagate. rename: iamKit.action( async (event, session) => { const name = String((await event.request.formData()).get('name')); return { project: await renameProject(event.params.id, name) }; }, { authorize: { action: 'projects:manage', resource: ({ event }) => ({ type: 'project', id: event.params.id }), }, }, ), }; ``` * **`guard(load, spec)`** requires a session, then the optional `stepUp` and `authorize`, then calls `load(event, session)`. Signed-out visitors are redirected to `loginRedirect ?? loginPath`, and denials become a 403 (or `deniedRedirect`). It also wraps `+server.ts` handlers that take the event. * **`action(fn, spec)`** runs the same checks but reports every IAM refusal as `fail()`: 401 `UNAUTHENTICATED`, 403 `MFA_REQUIRED`, `RECENT_AUTH_REQUIRED`, `IMPERSONATION_RESTRICTED`, or `ACCESS_DENIED`, 400 for invalid input, and 429 when rate limited. The page's `form` prop can then show `form.code`. Kit redirects and errors thrown by `fn` propagate, and so do IAM errors with a 5xx status. ```svelte title="src/routes/account/+page.svelte"
{#if form && 'code' in form}

{form.code}

{/if} ``` For direct administrative calls inside a guarded load or action, pass the caller's credential: `iam.api.identities.list(event.locals.iam.credential(), { tenantId: session.session.tenantId, limit: 50 })`. ### Sign-in and sign-out forms [#sign-in-and-sign-out-forms] The in-process client makes a no-JavaScript sign-in form a few lines long: ```ts title="src/routes/login/+page.server.ts" import { fail, redirect } from '@sveltejs/kit'; import { safeRedirectPath } from 'better-iam/svelte/kit'; export const actions = { default: async ({ request, locals, url }) => { const form = await request.formData(); let result; try { result = await locals.iam.client.auth.signIn({ tenantId: String(form.get('tenantId')), email: String(form.get('email')), password: String(form.get('password')), }); } catch (error) { return fail(400, { message: (error as Error).message }); } // Outside the try: redirect() throws, and a catch would swallow it. if ('mfaRequired' in result) redirect(303, `/verify?challenge=${result.challenge}`); redirect(303, safeRedirectPath(url.searchParams.get('next'))); }, }; ``` ```ts title="src/routes/logout/+page.server.ts" import { redirect } from '@sveltejs/kit'; export const actions = { default: async ({ locals }) => { await locals.iam.signOut(); redirect(303, '/'); }, }; ``` `safeRedirectPath` accepts only same-site paths and falls back to `/` for anything else (`//host`, schemes, backslashes, control characters). The helpers check step-up with `checkStepUp(session, requirement)`, which is exported for your own use and follows the server's rules: impersonated sessions never satisfy a recency requirement, and `mfa: 'fresh'` refuses remembered devices, assumed roles, and API keys. `signOut()` never throws for an already-dead session and always clears the cookie. ## Browser stores [#browser-stores] Components decide what to show from Svelte stores. The root layout load hands the server's session (and any decisions the first render needs) to the stores, the layout component creates them once, and descendants read them from context. **+layout.server.ts:** ```ts title="src/routes/+layout.server.ts" import { iamKit } from '$lib/server/iam'; export const load = async (event) => ({ ...(await iamKit.sessionData(event)), // { session } permissions: await event.locals.iam.authorize([{ action: 'projects:create' }]), origin: event.url.origin, }); ``` **+layout.svelte:** ```svelte title="src/routes/+layout.svelte" {#if $session.session}Signed in as {$session.session.identity.email}{/if} {@render children()} ``` **Any component:** ```svelte title="src/lib/ManageButton.svelte" {#if $canManage.allowed}{/if} ``` | Store | Value | | ------------------------------------- | --------------------------------------------------------------------------------------------------- | | `iam.session` | `{ status, session, error }`; `status` is `loading`, `authenticated`, `unauthenticated`, or `error` | | `iam.authorize(input, { initial? })` | `{ status, results, error, allowed(action, resource?) }` | | `iam.can(input, { initial? })` | `{ status, allowed }` | | `iam.accessible(input, { initial? })` | `{ status, resources, total, error }` | * **Inputs** can be plain objects or stores; with runes, use `toStore(() => ...)`. `authorize` takes `{ tenantId, checks, enabled? }` (a check's `resource` defaults to the tenant), `can` takes `{ tenantId, action, resource?, enabled? }`, and `accessible` takes `{ tenantId, action, type, limit?, offset?, enabled? }`. * **Fetching.** A query fetches only while subscribed. It refetches when its input or the signed-in identity changes, but not when a session refresh returns the same identity. Signing out resolves every check to denied (`reason: 'UNAUTHENTICATED'`). Each store has `refresh()`. * **Seeding.** `initial` seeds a query with a server load's answer (for example `data.permissions` from `locals.iam.authorize`, or a boolean for `can`), and that answer is used instead of the first fetch. * **Session.** `iam` also has `refresh()`, `signOut()`, `setSession(session)`, `store`, `client`, and `dispose()`. `createIam({ client, initialSession?, refreshOnFocus?, refreshIntervalMs?, server? })` reloads the session when the tab regains focus (`refreshOnFocus: false` turns it off) and never fetches while server rendering. * **Svelte 4.** Create the stores in a component and pass values the same way. * **Context.** `setIamContext(iam)` shares one instance with descendants; `getIamContext()` reads it. The stores only decide what to render. The server still enforces every operation. ## Deployment notes [#deployment-notes] * **Native modules.** Keep `better-iam` (or `@better-iam/server` and the adapter) out of the server bundle, for example with `ssr: { external: ['better-iam'] }` in `vite.config`. `adapter-node` already leaves `dependencies` external. * **One SvelteKit copy.** `redirect()` and `error()` are recognized by class. In a monorepo where the app and `@better-iam/svelte` could resolve different `@sveltejs/kit` copies, add `resolve: { dedupe: ['@sveltejs/kit', 'svelte'] }`. * **Build-time imports.** `vite build` imports server modules to analyze routes. Guard environment checks with `building` from `$app/environment`; the example uses a placeholder secret and `:memory:` while building. * **Cookies.** The helpers pass the IAM server's cookie attributes straight to `event.cookies.set`, including an explicit `secure` flag, so SvelteKit's localhost default doesn't change them. A stale cookie is harmless: `getSession()` returns `null`, and `signOut()` clears it. * **Origin.** Form posts are checked against SvelteKit's own CSRF protection, so with `adapter-node` set `ORIGIN` to the origin people browse, and keep `baseURL` on the same origin. ```js title="vite.config.js" import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [sveltekit()], resolve: { dedupe: ['@sveltejs/kit', 'svelte'] }, ssr: { external: ['@better-iam/server', '@better-iam/adapter-sqlite'] }, }); ``` ## Next steps [#next-steps] - [Express, Hono, Fastify](/docs/frameworks/node): The shared request helpers behind `locals.iam`, and the CSRF rule for other servers. - [Step-up](/docs/frameworks/nextjs/advanced#step-up): What `mfa: true`, `mfa: 'fresh'`, and `maxAgeMs` accept and refuse. - [Policies](/docs/guides/authorization/policies): What `authorize` rules actually evaluate. # Vue (/docs/frameworks/vue) > The @better-iam/vue plugin, composables, and IamCan component, with server-rendered sessions and hydrated decisions for Vue 3.3 and later. `@better-iam/vue` connects Vue 3.3+ applications to the [typed client](/docs/frameworks/client). Components could call the client themselves, but each would then load the session separately, track its own loading state, and keep showing buttons after a sign-out elsewhere on the page. The plugin keeps one reactive session for the app, exposes decisions as refs that follow reactive inputs, and carries server-rendered results to the browser. What it covers, and what it leaves to your server: * **Hydration.** Server-rendered sessions and permission results travel to the browser, so the first render matches the HTML without a request ([how](#server-rendering-and-hydration)). This is the main job it does for server-rendered apps. * **Sessions on the server and guards.** The composables never enforce anything. Guard pages and mutations on the server, for example with [Nuxt](/docs/frameworks/nuxt) server utilities or the [Express adapter](/docs/frameworks/node). * **Cross-site checks.** The client sends the `X-Better-IAM` header the IAM API requires; your own routes are checked by the server integration. | Export | What it does | Use it to | | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | [`createIam(options)`](#the-plugin) | The Vue plugin: one client and one session store for the app | `app.use(createIam({ client }))` once | | `useSession()` | Computed `status`, `session`, `error`, `isAuthenticated`, with `refresh`, `signOut`, `setSession` | Show who is signed in, sign out, apply a sign-in | | `useIamClient()` | The client passed to `createIam` | Call any API method from a component | | `useAuthorize(input)` | Several advisory decisions in one `authorizeMany` call, with `allowed(action, resource?)` | Decide which buttons and menu items to render | | `useCan(input)` | One decision as a `ComputedRef` | A single `v-if` | | `useAccessible(input)` | The managed resources of a type the person may act on | Render a list of what someone can open | | `useAgreements(input)` | The person's terms of use, the pending ones, and `accept` | Block the app until required terms are accepted | | `useAccessPaths(input)` | Whether an action is allowed and, if not, what the person can do about it | Build a "request access" screen | | [`IamCan`](#iamcan) | A component with `default`, `fallback`, and `loading` slots | Gate part of a template on one permission | | [`createHydration(state?)`](#server-rendering-and-hydration) | A plain-object store that carries query results from server rendering to the browser | Custom SSR setups | | `createSessionStore`, `isUnauthenticated` | The framework-agnostic session store and its error test | Use the store outside Vue | Everything it returns is advisory UI state: it decides what to render, and the server still enforces every operation. Nuxt applications use [`@better-iam/nuxt`](/docs/frameworks/nuxt), which installs this plugin for you, with the session loaded on the server. ## Install [#install] npm pnpm yarn bun ```bash npm i @better-iam/vue @better-iam/client ``` ```bash pnpm add @better-iam/vue @better-iam/client ``` ```bash yarn add @better-iam/vue @better-iam/client ``` ```bash bun add @better-iam/vue @better-iam/client ``` With the umbrella package, import from `better-iam/vue` and `better-iam/client`. ## Setup [#setup] ### Install the plugin [#install-the-plugin] Import the server instance as a type and install the plugin once. ```ts title="src/main.ts" import { createApp } from 'vue'; import { createIamClient } from '@better-iam/client'; import { createIam } from '@better-iam/vue'; import type { iam } from '../server/iam.js'; import App from './App.vue'; export const client = createIamClient({ baseURL: 'https://app.example.com' }); createApp(App).use(createIam({ client })).mount('#app'); ``` ### Use the composables [#use-the-composables] Inputs are getters here, so the queries re-run when the component's props change. ```vue title="src/components/Workspace.vue" ``` Composables used without the plugin throw `Better IAM composables need app.use(createIam({ client }))`. ## The plugin [#the-plugin] `createIam(options)` builds the plugin. Call it once per app (once per request when rendering on the server) and pass the result to `app.use`. `createIam` returns `{ client, store, install, dispose }`. `store` is the framework-agnostic [session store](/docs/frameworks/client#session-store), and `dispose()` removes the focus, visibility, and interval listeners the plugin installed. ## Composables [#composables] Each composable reads the plugin's session and client, so any component can ask for what it needs without passing them down. | Composable | Returns | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `useSession()` | Computed `status` (`loading`, `authenticated`, `unauthenticated`, `error`), `session`, `error`, `isAuthenticated`, plus `refresh()` (reload from the server), `signOut()` (sign out, then clear locally), and `setSession(session)` (apply a sign-in response) | | `useIamClient()` | The client passed to `createIam`, typed as you declare it | | `useAuthorize(input)` | `status` (`idle`, `loading`, `ready`, `error`), `results`, `error`, `allowed(action, resource?)` (false until results arrive), `refresh()` | | `useCan(input)` | `allowed` (a `ComputedRef`, false while loading or signed out), `status`, `refresh()` | | `useAccessible(input)` | `status`, `resources`, `total`, `error`, `refresh()`; the [reverse query](/docs/guides/authorization/queries) for managed types | | `useAgreements(input)` | `status`, `agreements`, `pending` (required and not yet accepted in their current version), `error`, `accept(agreement)`, `refresh()` | | `useAccessPaths(input)` | `status`, `allowed`, `reason`, `paths` (what the person could do alone to be allowed), `error`, `refresh()` | * **Inputs.** `useAuthorize` takes `{ tenantId, checks, enabled? }`, `useCan` takes `{ tenantId, action, resource?, enabled? }`, and `useAccessible` takes `{ tenantId, action, type, limit?, offset?, enabled? }`. Each accepts a plain object, a ref, or a getter such as `() => ({ tenantId: props.tenantId, ... })`. * **When they re-run.** A query re-runs when its input or the signed-in identity changes. A session refresh for the same identity does not re-run it. * **Signed out.** Every check resolves to denied with `reason: 'UNAUTHENTICATED'`, and reverse queries settle to an empty list, without a request. * **Transport errors.** When the session store reports a transport `error`, queries keep their last results; the next successful session refresh runs them again. * **Default resource.** Without a resource, `allowed()`, `useCan`, and `IamCan` check the tenant itself (`iam/{tenantId}`). `useAgreements({ tenantId })` and `useAccessPaths({ tenantId, action, resource })` return the same data as the [React hooks](/docs/frameworks/react#self-service-hooks). The first gives the person's terms of use with `pending` and `accept`. The second gives the self-service `paths` (`mfa`, `accept-agreements`, `activate`, `request-package`) the server verified would allow a denied action. Both are prefetched during server rendering like the other queries. ## IamCan [#iamcan] `IamCan` gates part of a template on one permission without any script code. ```vue ``` `IamCan` takes `tenant-id` and `action` (both required) and an optional `resource`. It renders the `loading` slot while the decision is pending, the default slot when allowed, and the `fallback` slot otherwise. The plugin registers it globally unless `registerComponents: false`; you can also import it from `@better-iam/vue`. ## Server rendering and hydration [#server-rendering-and-hydration] Why this matters: a server-rendered page arrives as HTML, and the browser then "hydrates" it by running the same components. Suppose a permission check ran on the server but starts over in the browser. The browser's first render has no answer yet, so a "Manage" button the server rendered disappears, Vue warns about a hydration mismatch, and the button comes back when the request finishes. Handing the server's answers to the browser avoids the flicker, the warning, and the duplicate requests. On the server (`server: true`, or whenever `window` is undefined), the plugin installs no listeners and does not load the session itself: pass the session you already have as `initialSession`. Queries in rendered components are awaited through `onServerPrefetch`. Each result is written to `hydration` under a key made of the query, the signed-in identity, and the input. In the browser, each hydrated result is used once instead of a fetch, so the first paint shows the same buttons the server rendered. After that, queries fetch normally. `createHydration(state?)` is a plain-object `IamHydration` (`get`, `set`, `delete`, plus `state`). Serialize `hydration.state` into the page on the server and pass the parsed object back on the client. Nuxt does all of this for you through its payload. For a custom setup, the server needs a client that reaches the IAM server in process, such as `req.iam.client` from the [Express adapter](/docs/frameworks/node), because the HTTP API refuses cookie requests without an `Origin`. **Server:** ```ts title="server/render.ts" import { createSSRApp } from 'vue'; import { renderToString } from 'vue/server-renderer'; import { createHydration, createIam } from '@better-iam/vue'; import App from '../src/App.vue'; app.get('*', async (req, res) => { const session = await req.iam.getSession(); // the Express adapter's per-request helpers const hydration = createHydration(); const vue = createSSRApp(App).use( createIam({ client: req.iam.client, initialSession: session, hydration, server: true }), ); const html = await renderToString(vue); // Escape '<' in the serialized state before inlining it (for example with a serializer such as devalue). const state = serialize({ session, hydration: hydration.state }); res.send(`
${html}
`); }); ``` **Browser:** ```ts title="src/entry-client.ts" import { createSSRApp } from 'vue'; import { createIamClient } from '@better-iam/client'; import { createHydration, createIam } from '@better-iam/vue'; import type { iam } from '../server/iam.js'; import App from './App.vue'; const state = (window as unknown as { __IAM__: { session: never; hydration: Record } }) .__IAM__; createSSRApp(App) .use( createIam({ client: createIamClient(), initialSession: state.session, hydration: createHydration(state.hydration), }), ) .mount('#app'); ``` `createSessionStore` and `isUnauthenticated` are re-exported from `@better-iam/client/session`, shared with `@better-iam/react`, together with the `SessionClient`, `SessionOf`, `SessionSnapshot`, `SessionStatus`, and `SessionStore` types. ## Next steps [#next-steps] - [Nuxt](/docs/frameworks/nuxt): The module that installs this plugin with server-rendered sessions and page guards. - [Express, Hono, Fastify](/docs/frameworks/node): Server guards and the in-process client for a custom server-rendering setup. - [Typed client](/docs/frameworks/client): Every API method, error codes, and passkey helpers. # Use the docs with AI (/docs/reference/ai) > Connect AI coding assistants to these docs through MCP, or give them the whole site as Markdown with llms.txt. AI coding assistants write better Better IAM code when they can read the real reference instead of guessing method names. This site offers the same content to tools as it does to people, in three forms: an MCP server your assistant can query, plain-text indexes for one-shot context, and a Markdown version of every page. ## Connect an assistant with MCP [#connect-an-assistant-with-mcp] The [Model Context Protocol](https://modelcontextprotocol.io) lets an assistant call tools while it works. This site serves an MCP endpoint at `/api/mcp` (Streamable HTTP, no sign-in required, read-only) with five tools: | Tool | What it does | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `search` | Full-text search over every page, the same index as the search dialog. | | `list_pages` | Lists every page with its URL and one-line description. | | `get_page` | Returns one page as Markdown, by URL (for example `/docs/guides/authorization/policies`). | | `lookup_api_method` | Returns the signature, HTTP route, permission, errors, and behaviour of one server API method, for example `groups.addMember`. | | `lookup_export` | Explains a function, hook, component, class, or constant a package exports, for example `useSession` or `@better-iam/core evaluatePolicy`, with its parameters and every entry point that exports it. | | `lookup_error_code` | Explains an `IamError` code (for example `ACCESS_DENIED`): what it means, why it happens, and how to handle it. | **Claude Code:** ```bash claude mcp add --transport http better-iam-docs https://docs.example.com/api/mcp ``` **Cursor:** ```json title=".cursor/mcp.json" { "mcpServers": { "better-iam-docs": { "url": "https://docs.example.com/api/mcp" } } } ``` **VS Code:** ```json title=".vscode/mcp.json" { "servers": { "better-iam-docs": { "type": "http", "url": "https://docs.example.com/api/mcp" } } } ``` Replace `https://docs.example.com` with the address where these docs are served (`http://localhost:4000` when you run them locally with `pnpm --filter @better-iam/docs dev`). ## Give a model the whole site [#give-a-model-the-whole-site] When a tool cannot call MCP, paste or fetch the site as plain text: | URL | Content | Use it for | | ----------------- | -------------------------------------------------- | --------------------------------------------- | | `/llms.txt` | An index: every page's title, URL, and description | Letting a model pick which pages to read | | `/llms-full.txt` | Every page as Markdown in one file | Loading everything into a long context window | | `/docs/.md` | One page as Markdown | Quoting a single page precisely | Any docs URL also returns Markdown when the request asks for it with `Accept: text/markdown`, so agents that fetch pages get Markdown without special URLs. Each page's **Copy Markdown** button and **Open** menu (with shortcuts to open the page in an AI chat) use the same Markdown. ## Keep answers accurate [#keep-answers-accurate] The reference pages, and the MCP lookup tools, are generated from the code (method names, signatures, routes, and error codes) with hand-written explanations on top, so they match the version of Better IAM these docs were built from. When an assistant's answer disagrees with your installed version, trust the TypeScript types in your editor: every server method is typed end to end from the `betterIam()` instance. # Changelog (/docs/reference/changelog) > Every notable change to the Better IAM packages, newest first. ## Unreleased [#unreleased] * Organization sign-in addresses, custom hostnames, and regions. Guide `docs/hosts-and-regions.md` (site: Deployment → Sign-in addresses and regions). * `hosts.patterns` gives every organization with an alias its own address, like an AWS account sign-in URL: `'{tenant}.signin.example.com'`, with the region if you like (`'{tenant}.signin.{region}.example.com'`), and `'{tenant}.localhost:3000'` in development. Requests on an organization's address are pinned to it: public sign-in calls act in it (`tenantId` may be left out), and another organization's `tenantId`, session, API key, or page is refused with `HOST_MISMATCH` (403). Organization origins are trusted like the deployment's own; cookies stay host-only. `nodeHandler` now keeps the request's `Host` when addresses are configured, and `hosts.forwardedHost` reads `X-Forwarded-Host` behind your own proxy. * Custom hostnames (`hosts.customHostnames`): new `hostnames` API group (`add`, `list`, `verify`, `setPrimary`, `delete`; actions `iam:hostnames:*`; collections `tenantHostnames`, `hostnameOwners`) verifies `login.acme.com` with a DNS TXT record (through `domains.resolveTxt`) and returns the CNAME to publish (`hosts.cnameTarget`). New codes `HOSTNAME_TAKEN` (409) and `HOSTNAME_NOT_ALLOWED`. Passkey ceremonies on a hostname outside the RP ID answer `FEATURE_DISABLED`. * Regions (`regions: { current, regions, locate? }`): `Tenant.region` (inherited from ancestors; organizations under the root default to the creating region), `tenants.create({ region })`, root-only `tenants.setRegion` (audit `tenant:region`). `tenants.lookup`, `domains.discover`, public sign-in calls, and organization addresses answer `WRONG_REGION` (421) with `region` and `location` (the sign-in URL in the home region) everywhere else; `IamClientError` gains `region` and `location`. `regions.locate(alias)` redirects aliases that live in another region's database. * `tenants.lookup({ host })`, and `region` / `signInUrl` in `tenants.lookup` and `domains.discover` results. `iam.hosts` (`region`, `resolve`, `signInUrl`, `allowed` for on-demand TLS checks). Every email and SMS message carries `signInUrl`, which `renderDeliveryMessage` passes to link builders; link builders now also receive the tenant ID for sign-in emails whose payload lacks it. * The Next.js and Express/Hono/Fastify/SvelteKit integrations keep the visitor's host on in-process calls, so server actions and loaders are pinned too. * Developer experience: the CLI is rebuilt on a declarative command registry, and everything it does is available from code. Guide `docs/cli.md`. * Every existing command keeps its flags, error codes, and results. New for all of them: `--flag=value`, `better-iam help ` / ` --help` with each flag's default and environment variable, "did you mean" suggestions, `help --json` (a machine-readable manifest, also `cliManifest()`), `--format json|compact|table` and `--query PATH`, `CODE: message` plus a `Hint:` line on failure, and exit status 2 for command-line mistakes (1 otherwise). * Configuration discovery: `--config`, then `BETTER_IAM_CONFIG`, then the nearest `better-iam.config.{mjs,js,ts,mts,cjs}` in the working directory or a parent, then `BETTER_IAM_DATABASE_URL` + `BETTER_IAM_SECRET` with no file (`configFromEnv`). Factories receive `{ command, env, cwd }`. A configuration module may export `cli = { defaults }` (flag defaults per command or `'*'`) and `commands` (project commands built with `defineCommand`, which get the same parsing, help, output, and completion). `init --typescript` writes `better-iam.config.ts`; the template is a side-effect-free `defineConfig` factory. * Token commands (`config-*`, `analyze`, `report`, `mine-roles`, `check-invariants`, `whoami`, and the new ones below) run in process or against a running server with `--url` / `BETTER_IAM_URL`, and read `--tenant` from `BETTER_IAM_TENANT`. * New commands: `api GROUP.METHOD` calls any HTTP API route (HTTPie-style `key=value`, `key:=json`, `key:=@file.json`, nested keys, `--data @file|-`; `api --list` shows every route and whether it needs a credential); `login` (password from `BETTER_IAM_PASSWORD` or a hidden prompt, authenticator or `--email-code` MFA, `--with-token` for API keys) saves sessions as profiles in an owner-only credentials file, with `logout`, `profiles [use|remove]`, and `token`; `can`, `explain`, and `who-can` answer authorization questions; `config-validate` checks tenant configuration offline (`--strict` fails on names the file does not define); `secret` prints a new deployment secret; `completion bash|zsh|fish|powershell`. * Configuration as code: `config-plan` / `config-apply` / `config-validate` accept `.mjs`/`.js`/`.ts` modules whose default export is the configuration or a factory of `{ tenantId, env }`, and `config-export --output x.ts|x.mjs` writes a typed module. * Code: `@better-iam/server` exports `defineConfig`, `configOptions`, and `defineTenantConfig`; `@better-iam/cli` exports `runCli`, `createCli`, `defineCommand`, `main`, `runBinary`, `loadConfig`, `configFromEnv`, `findConfigFile`, `lintTenantConfig`, `loadTenantConfig`, `localTransport` / `remoteTransport`, `listRoutes`, `createProfileStore`, `formatResult`, and `selectPath`. `runCli`'s `io` gains optional `err`, `prompt`, `stdin`, `cwd`, and `fetch`. The umbrella `better-iam` package now installs the `better-iam` binary. * Changed: results that some commands printed on one line (`purge`, `outbox`, `audit-prune`, `audit-export`, `config-export --output`) are now indented like the others; use `--format compact` for one line. * Feature flags at platform and tenant level. Guide `docs/feature-flags.md`. * New `features` API group: `create`, `update`, `delete`, `list`, `setTarget`, `listTargets`, `setOverride`, and `evaluate`. Catalog actions `iam:features:read`, `iam:features:manage`, and `iam:features:override` act on `iam/features/{key}`. Audit actions `feature:create`, `feature:update`, `feature:delete`, `feature:target`, and `feature:override`. New error `FEATURE_LOCKED`. * Flags on the root tenant are platform flags. Any other tenant's flags reach its own subtree, and a key belongs to the tenant closest to the root, so tenants cannot shadow platform flags. * Resolution order: kill switch, then the closest target or tenant override (a tenant's override beats a target on the same tenant, and a `locked` target silences overrides beneath it), then a stable percentage rollout per branch, then the default. Targets can lapse (`expiresAt`), and `internal` flags stay hidden from tenants. * `iam.features` (`evaluate`, `values`, `isEnabled`) for server code. React hooks `useFeatureFlags` and `useFeatureFlag`. `rolloutBucket` is exported. * Decisions expose the flags that are on as the `tenant.features` context key, read only when a condition names it and stripped from `resolveContext`/plugin context. `policies.test` fills it in, and policy lint knows it. * Console: an Administration "Feature flags" page and an organization "Features" page. * Collections `featureFlags` and `featureTargets` are removed with their tenant on purge. * Temporary credentials (STS): AssumeRole parity, session tokens, signed session JWTs, and OIDC web-identity federation. Guide `docs/temporary-credentials.md`. * `roles.assume` takes `sessionName`, `sourceIdentity`, `tags`, `format` and `audience`, and accepts API keys and session tokens as sources. The source's scopes or session policy bound only the `iam:roles:assume` decision (re-checked on every use), not the role session, which acts with the role's permissions within the trust `ceiling` and its own `policy`. Durations are capped by the new `sts.maxRoleSessionSeconds` (default 3600, up to 43200) and a per-trust `maxSessionSeconds`; a duration outside the range is `INVALID_INPUT`. `role:assumed` is recorded in the target tenant. * Identity trusts gain `maxSessionSeconds`, `passSourceAttributes`, `allowedTagKeys`, `sourceIdentityMode` and `description`; new root-only `trust.update`. New `analysis` finding `trust-passes-foreign-attributes`. * `sts.getSessionToken` (`iam:session-tokens:create`): a `biam_sts_` token for the caller's own identity from a user session or API key, bounded by an optional `policy` and the source's scopes, with an optional TOTP `mfaCode` step-up (`auth:mfa:step-up`) for the MFA-then-assume pattern; capped by `sts.maxSessionTokenSeconds` and `sts.maxSessionTokensPerIdentity`. New error `CREDENTIAL_CHAINING_DISABLED`. * `sts.getCallerIdentity` for every credential kind (CLI `better-iam whoami`), and `roles.listSessions` with allowlist projections that never carry hashes, policies or authority ids. * Revoke older sessions: `roles.revokeSessions`, `trust.revokeSessions` and `oidcProviders.revokeSessions` (`iam:roles:revoke-sessions`) move a `sessionsRevokedBefore` watermark and delete the matching rows; `trust.revoke` now deletes the trust's live sessions. `identities.revokeSessions({ keepApiKeys: true })` ends everything but the API keys. `oidcProviders.update` also ends every session issued through the provider (watermark plus deletion, for good) when it changes `jwks`, `jwksUri`, `algorithms`, `audiences`, `maxTokenLifetimeSeconds` or `clockToleranceSeconds`, or disables the provider; a rename, a `replayProtection` change or enabling keeps them. Rotating static `jwks` therefore makes workloads exchange again. * Session JWTs (`format: 'jwt'`, EdDSA/ES256) with the new `sts.jwt` option, `GET {basePath}/.well-known/jwks.json`, `iam.sessionTokens` (`jwks()`, `verify()`), and the runtime-neutral `createSessionTokenVerifier` (`@better-iam/server/session-tokens`, umbrella `better-iam/session-tokens`) with `verifyRequest`. IAM accepts a JWT bearer only with a matching stored session row. * Web identity (`sts.webIdentity`, off by default): tenant-managed `oidcProviders`, `trust.create({ kind: 'web-identity' })` with claim `conditions` over `token.` keys (a `token.sub` pin is mandatory, else `WEAK_TRUST_CONDITIONS`), the public `sts.assumeRoleWithWebIdentity` exchange (uniform `WEB_IDENTITY_REJECTED`, per-trust rate limit, single-use replay protection, SSRF-guarded key fetch), and the `trust.evaluateWebIdentity` dry run. * Session-aware policy keys: `principal.sessionId`, `tokenIssueTime`, `authTime`, `mfaTime`, `sessionTagKeys`, `sessionTags.`, `sourceTenantId`, `sessionName`, `sourceIdentity`, `webIdentityProvider`, `webIdentitySubject`, and `request.sourceIp`; audit events and webhook bodies carry `sessionContext`. New audit actions `role:assumed`, `role:assumed-with-web-identity`, `session-token:issued`, `role:sessions-revoked`, `auth:mfa:step-up`; new catalog actions `iam:trust:update`, `iam:roles:revoke-sessions`, `iam:session-tokens:create`, `iam:oidc-providers:*`. `@better-iam/server` now depends on `jose`. * Integrations: the client accepts JWTs and prefixed tokens, `@better-iam/next` principals add kind `session-token` with `sessionName`/`sourceIdentity`, the NestJS guard's CSRF check matches the bearer scheme case-insensitively, and the console labels the new kinds. * Migration: new tokens are prefixed and checksummed (`biam_ses_`, `biam_key_`, `biam_rol_`, `biam_sts_`, 58 characters; `credentialTokenScanPattern` for scanners). Legacy unprefixed tokens still work until they expire. Tooling that assumed 43-character tokens needs updating. * Migration: `auth.requireRecent` now refuses temporary credentials (role sessions and session tokens) with `RECENT_AUTH_REQUIRED`. Role sessions previously passed for five minutes after their source signed in; perform those operations from a signed-in session. * Migration: sign-in results no longer carry `tokenHash`: `SessionResult.session` is now `SafeSession` for every flow, in process and over HTTP. * Migration: new cross-tenant identity trusts default to `passSourceAttributes: false`, so source attributes no longer reach their role sessions unless enabled. Existing trusts keep passing them; turn it off with `trust.update` (the new finding lists them). `trust.create` and `trust.list` return the public projection (`requiresExternalId`, never `externalIdHash`), and the `roles.assume` response is a superset of the previous `{ token, session }`. * Migration: `credentials.revoke` only accepts API keys (`INVALID_CREDENTIAL` for any other session id). * Migration: the new server-owned context keys (and every existing `principal.*` key, `request.time`, `request.sourceIp` and `principal.sessionTags.*`) now strip same-named values supplied by `resolveContext` or plugins, even when the server leaves the key absent. The new session names are reserved: identity attributes named `sessionId`, `tokenIssueTime`, `authTime`, `mfaTime`, `sourceTenantId`, `sessionName`, `sourceIdentity`, `sessionTags`, `sessionTagKeys`, `webIdentityProvider` or `webIdentitySubject` fail with `INVALID_CONFIG`. * React Router: new package `@better-iam/react-router` (umbrella `better-iam/react-router`) for framework mode, v7.9+ and v8. `createIamRouter(iam, options)` gives: * `middleware` for the root route: per-request helpers in the router context, with cookies from the in-process client put on the response * `api` for an `api/iam/*` resource route * `guard(loader, spec)`: login and step-up redirects (`.data`-aware `next`), and a 403 `data({ code, message })` for error boundaries or `deniedRedirect` * `action(fn, spec)`: an Origin check first, then IAM refusals returned as `data({ code, message }, { status })` * `helpers(args)`, `requireSession`, `require`, and `sessionData` Guide `docs/react-router.md`, runnable `examples/react-router` (react-router-serve, typegen + `tsc`, smoke test), tests `tests/react-router.test.ts`. * Express, Hono, and Fastify: new package `@better-iam/middleware` (umbrella `better-iam/express`, `better-iam/hono`, `better-iam/fastify`, and `better-iam/middleware`). `createIamExpress` / `createIamHono` / `createIamFastify` do four things: * serve the IAM API (Express and Fastify through `iam.nodeHandler`, so node-only protocol mounts listed in `serve` work too; Express also accepts bodies a parser already consumed and restores a stripped mount path) * attach per-request helpers (`req.iam`, `c.get('iam')`, `request.iam`): memoized `getSession`, `requireSession`, `require`, batched `can`, `authorize`, `listAccessible`, `assertion`, `credential`, `signOut`, and an in-process typed `client` whose cookies land on the response * provide `requireSession({ stepUp })` / `authorize(action, { resource, tenantId, stepUp })` route guards, which also refuse cookie-authenticated unsafe requests from untrusted origins (`UNTRUSTED_ORIGIN` / `CSRF_REJECTED`; `trustedOrigins`, `csrf: false`, exported `checkRequestOrigin`) * map refusals to the IAM JSON envelope, or to `loginPath` / `stepUpPath` redirects for page navigations (`errorHandler` / `onError`) The framework-neutral core (`createRequestHelpers`, `enforceGuard`, `refusalResponse`, `checkStepUp`, `IamRequestError`) now also backs `@better-iam/svelte/kit`. Guide `docs/node-frameworks.md`, tests `tests/middleware.test.ts` (real Express and Fastify servers, a Hono app). * SvelteKit and Svelte: new package `@better-iam/svelte` (umbrella `better-iam/svelte` and `better-iam/svelte/kit`). `createIamKit(iam, options)` gives SvelteKit apps: * a `handle` hook that serves the IAM API and enforces `protect` path rules (login redirect with `?next=`, step-up page, `authorize` with `deniedRedirect` or a 403 carrying the IAM code) * per-request `event.locals.iam`: `getSession`, `requireSession`, `require`, batched `can`, `authorize`, `listAccessible`, `assertion`, `credential`, `signOut`, and an in-process typed `client` whose `Set-Cookie` answers land in `event.cookies`, so sign-in forms work without JavaScript * `guard(load, spec)` for server loads, `action(fn, spec)` for form actions (IAM refusals become `fail(status, { code, message })`), `sessionData(event)`, `safeRedirectPath`, and `checkStepUp` The browser entry `createIam({ client, initialSession })` gives Svelte 4/5 stores: `session`, `authorize`, `can`, and `accessible`. Inputs can be stores, and `initial` values come from server loads, so hydration needs no second request. It also exports `setIamContext` / `getIamContext`. Guide `docs/sveltekit.md`, runnable `examples/sveltekit` (adapter-node, smoke test, `svelte-check`), tests `tests/svelte.test.ts` and `tests/svelte-kit.test.ts`. * Storage queries run in SQL: `find()` filters become typed JSON conditions evaluated by the database, with pagination in SQL when the whole filter is expressible. Hot lookup fields (`INDEXED_FIELDS`: session token hashes, identity/group/role ids, email, OAuth artifact hashes, delivery queues) are indexed: partial expression indexes on SQLite/libSQL, one `jsonb_path_ops` GIN index on PostgreSQL, and id-ordered tenant and natural-key indexes. A session lookup among 20,000 sessions on SQLite fell from \~26 ms to \~0.25 ms. The SQLite adapter caches prepared statements. Schema steps are recorded by name in `iam_migrations` (new migration `0002_query_indexes`). Results are ordered by id in code-point order on every adapter. Identifiers (collection, id, tenant, natural key) with an unpaired surrogate are refused (`INVALID_RECORD`) instead of silently aliasing the U+FFFD spelling; reads and deletes treat them as absent. Field values still accept any string; the PostgreSQL adapter stores U+0000 and unpaired surrogates in a reversible jsonb-safe encoding (`encodeJsonbDocument` / `decodeJsonbDocument`), and migration `0002` rewrites existing rows that need it. SQLite and libSQL compare numbers by their JSON text, so integers above 2^53 and extreme exponents match exactly. PostgreSQL migrations wait up to ten minutes for another instance's migration. New exports: `RecordDriver.query`, `planQuery`, `MAX_QUERY_CONDITIONS`, `JSONB_ESCAPE_KEY`, `sqliteSelect`, `postgresSelect`, `applyMigrations`, `schemaMigrations`, `matchesFilter`, `compareIds`, `storableString`, and the adapter conformance suite `@better-iam/core/conformance` (`adapterConformanceCases`, `runAdapterConformance`), now run against SQLite, libSQL, and PostgreSQL. * Storage operations: * Store snapshots move a deployment between databases and adapters, for example SQLite to PostgreSQL. CLI `store-export --output FILE` writes a consistent JSON Lines snapshot with a header and a counting trailer. `store-import --input FILE` loads it into an empty database in one transaction and rolls back on a truncated, corrupt, or refused snapshot. `store-copy --target-config FILE` copies directly. Copying a database onto itself fails with `SAME_DATABASE`. Core exports `exportStore`, `importStore`, and `copyStore`. * SQLite and libSQL: a second adapter instance of the same kind on the same file, used inside the first one's transaction, now fails with `DATABASE_IN_USE` instead of waiting forever for its own caller's lock. Paths are compared after resolving links, Windows short names, and `file://` URLs. A SQLite and a libSQL adapter on one file still wait for the busy timeout and fail with `STORAGE_BUSY`. * Retention sweep: `iam.sweepExpired({ limit, batchSize, deliveryRetentionMs, graceMs })` and CLI `sweep [--limit N] [--retention-days N]` delete, in short batches, records that nothing uses any more and that until now accumulated forever. API keys and other session kinds, invitations, access requests, usage records, and pending deliveries are kept. Migration `0004_expiry_indexes` indexes `expiresAt`, `deliveredAt`, and `failedAt` across each collection; the sweep walks these indexes once per run and steps over records it keeps. Shared Signals deliveries now record `failedAt`, and dispatched audit hooks record `deliveredAt` (`tests/retention.test.ts`). Swept: * user and role sessions, trusted devices, and relationship tuples past expiry; * OAuth artifacts and login states (grants 31 days after expiry), and SAML request, relay, and replay records; * delivered or abandoned outbox messages and abandoned Shared Signals deliveries past the retention; * dispatched audit hook rows. * `find()` accepts an id cursor (`after`) for keyset pagination, served by the primary-key and tenant indexes; snapshots page with it, so exporting a large audit log does not slow down page by page. * Migration `0005_lookup_indexes` indexes `sourceSessionId` and `trustId` (SQLite and libSQL). Lookup indexes are now listed per schema step (`LOOKUP_INDEX_STEPS`), so released steps never change; `INDEXED_FIELDS` is their union. * Continuous audit archiving: the `auditArchive: { write(batch), batchSize }` option and `iam.archiveAudit()` (CLI `audit-archive [--tenant ID] [--limit N]`) copy each tenant's audit chain to independent storage. * Batches are verified and in chain order, and a per-tenant cursor records what has been archived. * A per-tenant lease lets one run at a time archive a tenant (`busy` lists the others). * `createJsonlAuditArchive({ directory })` is a crash-safe, write-once file sink (`ARCHIVE_CONFLICT` for a different batch under an existing name). CLI `audit-verify-archive --directory DIR --tenant ID` verifies an archive without the database. * Broken chains (`AUDIT_CHAIN_BROKEN`), sink failures, and gaps are reported rather than skipped. * Once a tenant has an archive cursor, `pruneAudit` deletes only events the archive holds, in every process, and reports `heldForArchive`; `doctor` reports `audit-archive-behind` (`tests/audit-archive.test.ts`). * `better-iam init` configurations read `BETTER_IAM_PREVIOUS_SECRETS`. * Deployment secret rotation: the new `previousSecrets` option keeps authenticator secrets, webhook secrets, pending deliveries, emailed links, and assertions sealed or signed under an old secret working. New values use `secret`. `iam.rotateSecrets()` (CLI `rotate-secrets [--dry-run]`) re-seals stored values with the current secret. `iam.assertionKeys()` lets downstream services accept both keys during the switch. `verifyAssertion`, `IamAssertionModule.forRoot`, and the Next.js `verifyAssertionToken` all accept a key list. The run reports `complete` and `done`, and `doctor` never calls a partial sample done (`secret-rotation-unverified`). The deployment guide describes a staged rollout that is safe with several instances. The outbound SCIM provisioner takes `previousEncryptionKeys` and offers `rotateKeys()`, for keys an application derives from the secret. `doctor` reports values still pending rotation, and values no configured secret opens (the secret was replaced outright). Auth exports `openSecret`, and `decryptSecret` accepts a list of secrets (`tests/secret-rotation.test.ts`). * Deployment self-check: `iam.selfCheck()` reports findings with a severity and a fix: schema behind, not bootstrapped, SQLite durability that can corrupt, in-memory or asynchronous-commit storage, a placeholder secret, a short metrics token, no email transport, and scheduled jobs that are not running (sweep backlog, lapsed records `purge` should have removed, stalled or abandoned outbox messages, stalled audit hooks). `doctor` prints them, and `doctor --strict` exits non-zero on any error or warning (`tests/self-check.test.ts`). * `doctor` adds `storage` from the new optional `IamStore.describe()`: schema version, applied migrations, record counts per collection, and adapter settings. A database that cannot be reached fails `describe()` instead of reporting as empty. * Ordered reads: optional `IamStore.findOrdered` and the `findOrdered` helper page by a numeric field in SQL (migration `0003_ordered_indexes`). Audit `list` and `export` and `pruneAudit` use it, so their cost follows the page size instead of the log length. Optional `IamStore.collections()` lists collections. * SQLite file databases default to WAL with `synchronous = FULL`; `journalMode: 'delete'` and `durability: 'normal'` opt out. * Session validation writes `lastSeenAt` at most once a minute, or once per tenth of the idle timeout when that is shorter. * Authorization reads a subject's bindings with indexed lookups per group instead of scanning the tenant's bindings. * `instrumentStore` and `summarizeStoreCalls` report storage calls without values; `pnpm bench:scale` measures hot paths at a configurable deployment size. * Tests: `tests/store-snapshot.test.ts`, `tests/store-describe.test.ts`, and `tests/retention.test.ts`. * Security: an assumed-role session whose source account owned another tenant passed the target tenant's owner gates (`identities.setOwner`, owner offboarding) and satisfied `principal.owner` / `principal.rootAdmin` policy conditions there. Those gates now require an owner of the target tenant on an ordinary session, and role sessions report `principal.owner` and `principal.rootAdmin` as false (`tests/owner-gates.test.ts`). * `auth.getSession` and `auth.listSessions` no longer return a session's `uniqueKey`, which held its token hash; `SafeSession` omits it. * Role mining: `roleMining.suggest` (`iam:analysis:read`) finds direct bindings a group already covers, roles every member of a group holds directly, roles with identical grants, and role bundles many people hold together (closed itemsets, skipped when a package or inheriting role already matches); `roleMining.apply` (`iam:analysis:update`) carries out the binding suggestions in one transaction under the original grant authority; `roleMining.outliers` reports access few peers (same manager or identity attribute) hold and access most peers hold that a person lacks. CLI `mine-roles`, console Role mining page. * Access usage tracking: the `accessUsage` option records which actions each identity was allowed to use (authorization checks and provisioning operations, not root overrides or impersonation), buffered in memory and written in batches to the new `accessUsage` / `accessUsageTracking` collections (`iam.flushAccessUsage()` writes immediately). `roleMining.usage` lists it and `roleMining.rightSize` reports bindings whose holders used none or only some of a role's actions in a window, plus per role the actions nobody used. Console: a Least privilege card on the Role mining page (the console enables tracking). * Console redesign: grouped, collapsible sidebar sections with a "Jump to…" page filter; a Governance hub per organization (urgency-ranked "Needs attention" list and tiles for findings, invariants, separation of duties, unused access, role mining, certifications, approvals, and terms of use); a "Governance at a glance" card on the overview; and an Organization health page in the administration panel that ranks every organization by findings and broken invariants. * Review fixes for the governance features: the usage recorder's timer and early writes no longer inherit the async context of the transaction that recorded first (which broke periodic writes and could abort `authorize`), failed writes back off; `listAccessible` counts as usage and root overrides do not; enforced invariants also guard resource deletion, root grants, campaign closes, and agreement publishing, evaluate every subject, and refuse changes that make them unevaluable; role mining no longer treats package-owned memberships as cover or suggests group bindings for groups with inactive members; sign-in evidence (recommendations and the dormant-access finding) ignores "view as" sessions; `accessPaths.find` masks deny reasons; `impact.preview` is refused while impersonating. * Review recommendations: `roleMining.reviewRecommendations` suggests keep or revoke for each access-certification item from account status, recorded usage (when it covers the window), or the last sign-in, with the reason; the console's campaign page shows them. * Change impact preview: `impact.preview` (`iam:policies:simulate`) applies a candidate role update, policy document, or role deletion with the real validation and edit rights inside an always-rolled-back transaction and reports, per holder of the affected roles (including inheritors and group members) and per resource, the actions gained and lost. Console: a Change impact page. * Access invariants: `invariants.create/update/delete/list/run` (built-in actions `iam:invariants:read|manage`, collection `accessInvariants`) state who may, or must never, perform an action on a resource (one identity, a group, everyone with an attribute value, or everyone). `run` evaluates them; `enforce` mode re-checks them around every access-changing operation and refuses a change that newly breaks one with `INVARIANT_VIOLATION`, while pre-existing violations do not block unrelated work. `impact.preview` reports invariants a candidate change would break or fix. Console: an Access invariants page. CLI `check-invariants --tenant ID [--fail-on-broken]`. Scheduler job `iam.checkInvariants()` / CLI `monitor-invariants` records `invariant:broken` and `invariant:restored` audit events once per status change (`lastCheck` on each invariant). * Terms of use: `agreements.create/update/delete/list/status` (built-in actions `iam:agreements:read|manage`, collections `agreements` / `agreementAcceptances`) publish versioned agreements with optional periodic re-acceptance; members call `agreements.listMine` / `accept` without a permission (audited `agreement:accept`). Policies see the new context keys `principal.agreements` and `principal.pendingAgreements` (known to the linter and reserved from identity attributes), so a deny statement can require acceptance. Console: a Terms of use page and an acceptance banner for members. * Self-service access paths: `accessPaths.find({ tenantId, action, resource })` tells a denied person what they could do themselves — step up to MFA, accept pending terms of use, activate one of their eligible bindings, or request a requestable package — each verified by simulating it in a rolled-back transaction; needs only an ordinary session. * `@better-iam/react` and `@better-iam/vue`: `useAgreements` (pending terms of use and `accept`) and `useAccessPaths` (self-service ways to be allowed). * Configuration as code covers `invariants` (subjects by group name, member email, attribute, or everyone) and `agreements` (a content change publishes a new version); both kinds are exported only when present, so existing documents are unchanged. * Birthright access packages: `autoAssign` rules (policy-condition include/exclude clauses over identity attributes, kind, owner flag, email, email domain and verification, manager, and direct group memberships) give a package to every matching identity and take it away when they stop matching, with an optional grace period. Rules run under their owner's grant authority and rights, re-checked on every run (suspended, never widened, when the owner leaves or loses rights). They reconcile after identity changes and rule saves, through `packages.reconcile`, and through the new scheduler job `iam.reconcilePackages()` / `better-iam reconcile`; brakes hold back unusually large unattended changes until confirmed. Also: `packages.previewAutoAssign`, `listAssignments({ source })`, `automatic` on assignments, `packages.assign` taking over automatic assignments, configuration documents carrying `autoAssign` (with group names), the console rule editor and preview, the new collection `packageRuleIssues`, and `package:auto-*` audit events. * `@better-iam/next` auth forms, step-up, service credentials, and background work: * **Auth forms.** `iamNext.authActions(options)` (and `createAuthActions`) returns drop-in server actions: `signIn` (password or emailed sign-in code, then authenticator, emailed, or recovery codes, first-time enrollment with one-time recovery codes, 'keep me signed in' and 'remember this device'), `reauthenticate` (step-up that ends the session it replaces), `signOut` (clears a stale cookie too, through the new `clearSessionCookie()`), `requestPasswordReset`, `resetPassword`, `signUp`, `verifyEmail`, and `acceptInvitation`. The organization is resolved from `tenantId`, an `org` slug, `resolveTenant`, or the email's verified domain. They return a serializable `AuthFormState` that never holds passwords, codes, tokens, or sessions. `@better-iam/next/client` adds matching unstyled, accessible forms that work without JavaScript (`SignInForm`, `ReauthenticateForm`, `PasswordResetRequestForm`, `PasswordResetForm`, `SignUpForm`, `InvitationForm`, each with a `*View` twin). * **Step-up.** `stepUp: { mfa: true | 'fresh', maxAgeMs }` on `page`, `route`, `action`, `requireSession`, `pages.withSession`, and `pages.api` redirects pages to `stepUpPath?next=&reason=` and answers handlers and actions with `MFA_REQUIRED`, `RECENT_AUTH_REQUIRED`, or `IMPERSONATION_RESTRICTED` (403). `checkStepUp` is exported, and page errors carry a `BETTER_IAM_STEP_UP:` digest for `error.tsx`. * **Service credentials.** `apiRoute()` accepts API keys and assumed roles through `iam.authenticate` and passes a whitelisted `IamPrincipal`. * **Background work.** `dispatchAfterResponse` schedules outbox and event dispatch with `after()` after every in-process call and every POST to the mounts. `iamNext.background` offers `dispatch()`, `schedule()`, and `cron()`, a route for schedulers: bearer `CRON_SECRET`, fails closed, and runs isolated tasks (purge, audit retention, digest, reminders, outbox, events). * **Hardening.** `route()`, `apiRoute()`, and `pages.api()` refuse cookie-authenticated cross-origin mutations (`CSRF_REJECTED` / `UNTRUSTED_ORIGIN`; new `trustedOrigins` option) and map only `IamError` / `IamClientError` to responses. `safeRedirectPath` no longer lets dot segments (`/.//evil.example`) normalize into a protocol-relative redirect. `sessionForClient()` and `pages.withSession` props drop the session's `uniqueKey`. Sessions refused for network or tenant-tree reasons read as signed out. The umbrella `better-iam/next/client` re-exports by name, because Next rejects `export *` in a `'use client'` module. * **Example.** `examples/nextjs` uses all of this: the login, step-up, and password-reset pages, a development inbox, `apiRoute`, and a cron route. * SCIM directory sync: the enterprise extension's `manager` becomes `Identity.managerId` (`mapManager`, on by default; by SCIM ID, `externalId`, or `userName`, back-filled when the manager arrives later, loops and self-references skipped, administrator-set managers never cleared). SCIM can no longer reactivate or regroup an identity an administrator deleted. `createScimService` serves a JSON administration API for consoles at `{adminBasePath}/connections/{list,create,rotate,revoke,groups,mappings}` (default `/scim/admin`; CSRF header, Origin check, 64 KiB bodies) and gains `listGroups`. Console: a Directory sync page (connections with their SCIM endpoints, one-time tokens, rotation, revocation, and group-to-role mappings), inbound SCIM mounted at `/api/iam/scim/v2`, and the console's IAM route now forwards `PUT`, `PATCH`, and `DELETE`. * Policy linter: `analysis.lintPolicy` (and the exported `lintPolicy`) checks a candidate or stored document against the catalog and reports statements that are valid but probably wrong: unknown context keys such as `request.ip`, denies on optional keys that silently never fire, negated conditions over unresolvable variables, arrays compared with string operators, type mismatches, shadowed allows, duplicates, and wildcard administration. Its shadowing check is budgeted against adversarial documents. Console: a Lint card with a draft checker on each policy page. * Access analysis gains `standing-privileged-access`, `unused-eligible-binding`, `orphaned-manager`, `manager-cycle`, and `policy-lint` findings. * Manager-review certifications: `reviewerMode: 'manager'` routes each person's items to their manager, who decides them with `certifications.review` without holding a certification permission; `certifications.listMine`, `certifications.remind` (`certification-reminder` emails), and `autoClose` with the deployment job `iam.closeOverdueCertifications` / CLI `close-certifications`. `renderDeliveryMessage` renders `certification-review` and `certification-reminder` with a `links.certification` button. Console: "Assigned to you" review queue, reviewer column, "Remind reviewers", and manager-mode, due-date, and auto-close fields. * Separation of duties also covers access-package assignment and approved package requests. * Access-package and access-lifecycle hardening from a multi-agent review: * `packages.extend` now needs the rights `assign` needs, plus a grant authority, whenever it lengthens an assignment or removes its end. It moves the package's bindings to the extender's authority. Shortening still needs only `iam:packages:assign`. * Request and activation decisions are refused from impersonation sessions (`IMPERSONATION_RESTRICTED`). * `packages.listApprovals` lists only requests the caller holds `iam:packages:approve` on. * Requests that name approvers but would reach none are refused. * A request lapses no later than the end it asks for. `listRequests` reports lapsed requests as `expired`. * Tightening a package cancels pending requests that no longer fit it. A direct `assign` marks the pending request approved. * Package bindings are the assignment's own (their own uniqueness key), so they never replace or depend on hand-made grants. * Packages that share a group hand its membership to each other. * Hand edits (`bindings.update`, `groups.updateMember`, re-adding a lapsed member) take a package's record over. * Assignments whose bindings no longer grant are reported `broken` and can be assigned or requested again. * Offboarding revokes package assignments under package ownership. * `groups.delete` refuses an approver group still in use. * Offboarding hands reports to a successor without creating self-management or cycles. A manager must be active, and tombstones drop their `managerId`. * The access report ignores disabled expired identities, lapsed memberships, and activations that can no longer grant. The purge worker removes the activations of purged bindings and disabled identities. * Expiry-reminder dedupe is kept in a new `expiryReminderMarks` collection, so it survives audit pruning. * Configuration documents validate package and binding durations, treating `null` as no cap, and match package names case-insensitively, so a case-only difference is a rename. * `bindings.update` accepts a `managerApproval`-only change. * CLI `remind --within-days` is limited to 1–365. * The console member page keeps name and attributes, manager, and deactivation in separate forms. * Shared Signals Framework transmitter: `createSharedSignalsTransmitter` (`better-iam/oauth`) turns IAM activity into signed Security Event Tokens (RFC 8417) and pushes them (RFC 8935) to each tenant's receivers. It covers CAEP `session-revoked` (sign-outs and administrator or tenant-wide revocations) and `credential-change` (password, authenticator, passkey), and RISC `identifier-changed` (email), `account-disabled` (offboarding, expiry), and `account-purged`. Subjects are `iss_sub` or `email`, events carry `initiating_entity` and the audit event as `txn`. Streams are managed by tenant administrators (`createStream`/`listStreams`/`getStream`/`updateStream`/`deleteStream`, built-in actions `iam:ssf:streams:*` on the new internal resource type `ssf`; audited) with encrypted receiver credentials, event and subject filters, pause and resume, `verifyStream` verification events, delivery history, retries with backoff (`dispatch`), and `/.well-known/ssf-configuration` metadata. New collections `ssfStreams` and `ssfDeliveries`. * MCP-ready OAuth: dynamic client registration (RFC 7591) at the discovered `registration_endpoint` when `createOAuthProvider({ registration })` is set. Registrations present a tenant-scoped token from `createRegistrationToken` (`listRegistrationTokens` and `revokeRegistrationToken` manage them; tokens are stored hashed with a use limit) or pass the host's `registration.anonymous` hook. New clients are bound to that tenant, public with PKCE unless `allowConfidential`, limited to the authorization code and refresh grants, HTTPS or loopback or reverse-domain redirect URIs, and the allowed scopes and resources. Nothing the provider would fetch can be registered. They are audited and listed with `registeredVia`. New collection `oauthRegistrationTokens`. RFC 9728 protected resource metadata (`protectedResourceMetadata`, `protectedResourceMetadataUrl`, `createProtectedResourceHandler`), and the verifier's `challenge(error, realm, { resourceMetadata, scopes })` points clients at it. `createResourceGuard` combines metadata serving, token verification, and RFC 6750 challenges into one `check(request)` for MCP servers and other APIs. Plain OAuth 2.1 authorization codes (no `openid`) now receive a refresh token when the client is registered for the refresh grant, since MCP hosts send neither `offline_access` nor `prompt=consent`. OpenID requests are unchanged. `tests/oauth-mcp-integration.test.ts` runs the whole chain against a real server: challenge, discovery, registration, consent, the guarded call, and revocation on sign-out. * Console email verification: `/cloud/verify-email` completes a `verify-email` link on a click, the account page shows an unverified address with a "Resend verification email" button, and the deliveries page opens verification links in development. * Security notices link home: `renderDeliveryMessage` accepts the message's `tenantId` and a `links.account({ tenantId })` builder; `new-sign-in` and `sign-in-failures` emails then carry a "Review your account" button (text and HTML). The console's deliveries page points it at the organization's account page. * Cookie options: `http.cookieSameSite` (`lax` by default, or `strict`) and `http.persistentCookies` (`true` by default). A request that issues a session may send `X-Better-IAM-Persistent: 0` (or `1`) to receive a browser-session cookie that disappears when the browser closes, or a lasting one, regardless of the default; the server session keeps its own lifetime either way. Preflight responses now also allow `X-Request-Id` and `X-Better-IAM-Persistent`. Console: a "Keep me signed in on this browser" checkbox on the login page, carried through the second-factor step. * Discoverable passkey sign-in: `auth.beginPasskeyAuthentication({ tenantId })` without an email issues options that name no credential, so the authenticator offers its own passkeys for the relying party, and `finishPasskeyAuthentication` finds the account from the presented credential (user handle and ownership are still checked; disabled accounts are refused). Such anonymous starts are limited per address at ten times the ordinary allowance. Console: the login page offers passkeys in the email field through browser autofill as soon as it loads, and "Sign in with a passkey" no longer needs an email. * Client options: `onUnauthenticated(error)` runs once per request the server refused as `UNAUTHENTICATED` (a lapsed or revoked session; never a wrong password), so an application redirects to its login page in one place; `retryRateLimited` (`true` or `{ maxWaitMs }`, off by default) retries a `RATE_LIMITED` call once after the server's `retryAfterMs` when that wait is short enough; `requestId` (`true` or a function) sends an `X-Request-Id` on every request, which the server echoes and records on its spans, and `IamClientError.requestId` carries it for support tickets. * Named passkeys: `finishPasskeyRegistration` accepts a `name` (at most 64 characters; the default names the device kind from the authenticator's transports), `listPasskeys` returns `name`, `createdAt`, `lastUsedAt` (stamped by passkey sign-ins and passkey MFA), `deviceType`, `backedUp`, `aaguid`, and `transports` newest first, and `auth.renamePasskey({ id, name })` relabels one (audited as `auth:passkey:rename`). Console: the account page names each passkey with "synced" or "this device only", when it was added and last used, an inline rename, and a name suggestion (browser and system) when adding one. * Tenant policy `bindSessionsToIp`: a user session is accepted only from the client IP it was issued from; presented from another address it is refused with `SESSION_NETWORK_MISMATCH` (401) and the attempt is recorded in the person's trail as `auth:session:mismatch` with both addresses, so a stolen cookie is useless elsewhere and the person signs in again from the new network. Sessions and requests without a recorded address are not judged. Console: the toggle and status on organization settings. * Network blocks: `security.blockNetwork({ tenantId, network, reason, durationMs?, platform? })` (`iam:security:manage`, recent authentication, audited as `security:network-block`) refuses every authentication flow and live session whose recorded client IP falls in an IPv4/IPv6 address or CIDR block with `IP_BLOCKED`, before rate limits or credentials are examined; root administrators set `platform` blocks on the root tenant for the whole installation, organizations block for themselves, a `durationMs` (one minute to a year) makes a block lapse, `security.unblockNetwork` lifts it, and `security.listBlocks` (`iam:security:read`) reports them with `active`. The caller's own address is refused. Console: "Block for a day" per source address and a platform block list on the admin Sign-in failures page, and a "Blocked networks" card on organization settings. * Brute-force defenses: `authentication.rateLimits.ipAttempts` (off by default) caps attempts per client IP and tenant across every authentication flow, on top of the per-account limits, so credential stuffing and password spraying from one address run out no matter how many accounts they name (needs a recorded IP; `identities.unlock` never clears it). `authentication.failedSignInAlerts` (off by default; needs `sendEmail`) queues one `sign-in-failures` email (`attempts`, `time`, `ip`, `userAgent`; rendered by `renderDeliveryMessage`) the moment a person's failed attempts since their last sign-in reach that number, once per streak. The console enables the alert at five attempts whenever it has a mail transport, and the admin panel gains a Sign-in failures page: failed attempts across organizations for the last hour, day, or week, grouped by source address and by targeted account (with the account's current streak and an unlock), plus the latest attempts. Console session, device, and activity lists now name clients ("Chrome 128 on Windows · 203.0.113.7") instead of showing raw user-agent strings. * Idle-timeout warning: `auth.getSession` returns `limits` (`lifetimeMs`, `idleTimeoutMs`, and `idleExpiresAt`, honouring the tenant policy), and both console panels warn two minutes before a session lapses for inactivity (or reaches its maximum length) with a countdown, "Stay signed in", and "Sign out now", returning to the login page once it has lapsed (which then explains the inactivity or the reached lifetime via `?reason=idle|expired`); activity in the tab keeps the session alive without a prompt. * Sign-in record and failed attempts: a wrong password, authenticator or emailed code, or recovery code presented for a real, active account is recorded as an `auth:signin:fail` audit event (`metadata.reason`, `ip`, `userAgent`) in its own transaction and counted per person; `auth:session:create` events carry the method and client too. Every new session from a sign-in flow carries `session.previousSignIn` (previous sign-in time and client, `failedAttempts` since, latest failed attempt's time and client; absent on a first sign-in) and the count restarts. `auth.listSecurityEvents` returns event `metadata`. Console: a dismissable notice above every page when attempts failed since the previous sign-in, and "Previous sign-in" / "Failed attempts since" on the account page, whose security activity now names the reason and client of each event. * `docs/enterprise.md` walks one customer organization through verified domains, SAML or Entra ID single sign-on, sign-in policy, SCIM provisioning in and out, end-to-end offboarding, and audit. The console runs a periodic outbound provisioning sync so that expiries, which emit no event, still reach connected applications. * HTTP hygiene and account status: every JSON response carries `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer` beside `Cache-Control: no-store`; a plain `X-Request-Id` is echoed on responses and recorded as `requestId` on `http` spans. `auth.listSessions` marks the calling session with `current`, and `auth.mfaStatus` reports the authenticator state, unused recovery codes, passkeys, remembered devices, and whether the session passed MFA (the console account page warns when recovery codes run low). The admin panel gains a Live sessions page: every unexpired session across organizations, filtered by organization, kind, name, email, or IP, with impersonators marked and a per-person sign-out. * Console sign-in links and codes: the login page offers "Email me a sign-in link or code" (passwordless email is enabled whenever the console server has a mail transport); a link is redeemed at `/cloud/magic` on a click, a six-digit code is typed on the login page itself (`startPasswordless` with `kind: 'code'`, `finishPasswordless` with the code, resend and back), and the shared `MfaChallenge` step (authenticator, enrollment, recovery code, emailed code, passkey, remember-device) now serves password and link sign-ins alike; `mfaChallengeState` prepares it from any `mfaRequired` outcome. The deliveries page opens `magic-link` links in development. `tenants.usage` reports `mfaEnrolled` (people with an authenticator or a passkey) and the settings usage card shows MFA adoption. * Managers: `Identity.managerId` (`identities.create/update({ managerId | null })`: another active identity of the tenant, never oneself or a report, so reporting lines stay acyclic), `identities.listReports`, and offboarding hands a manager's reports to the successor (`reportsReassigned`) while deleting an identity clears them. `managerApproval` on an eligible binding or an access package lets the requester's manager decide on activation and package requests (alongside an approver group, if one is named) and emails them each request. The configuration document carries the flag; the console shows the manager and reports on the member page and offers the option on the assign-a-role and package forms. * Expiry reminders and assignment extension: `iam.sendExpiryReminders({ tenantId?, withinMs? })` (CLI `remind`, a deployment operation like `digest`) emails each person whose account, direct role bindings, group memberships, or package assignments end within the window (seven days by default) one `expiry-reminder` message listing them (`items` as JSON with kind, name, and end), once per item and end date, recorded as `identity:expiry-reminder`; moving an end brings a fresh reminder. `packages.extend({ packageId, identityId, expiresAt | null })` moves the end of an assignment and of every binding and membership it created together (audited as `package:extend`; console card on the Access packages page). * Self-service package requests: a `requestable` package (optionally with an `approverGroupId`) can be asked for with `packages.request({ packageId, expiresAt?, justification? })` under `iam:packages:request`; the request waits for the tenant's `approvalLifetimeMs`, approver-group members are emailed (`package-request`), and `packages.approveRequest` / `denyRequest` (`iam:packages:approve` on the package, group membership when one is set, never one's own) decide it, assigning under the approver's authority and emailing the requester (`package-decided`). `packages.cancelRequest`, `listRequests({ packageId?, identityId?, status? })`, `listApprovals`, and `listMine` (requestable packages with the caller's status on each) complete the flow; lapsed requests are swept by the purge worker (`expiredRequests`), offboarding cancels pending ones, the configuration document carries `requestable` / `approverGroup`, and everything is audited as `package:request`, `package:request-approved|denied|cancelled`. `packages.revoke` now needs only `iam:packages:assign` on the package (the assignment owns its records, whichever authority issued them). Console: request/approve cards on the Elevate page, request settings and a requests table on the Access packages page. * Access packages: `packages.create/update/delete/get/list` bundle roles and groups (`AccessPackage`, actions `iam:packages:create|read|update|delete|assign`). `packages.assign({ packageId, identityId, expiresAt?, justification? })` grants the whole bundle in one transaction as ordinary identity bindings and group memberships tagged with the assignment, skipping what the person already holds, honoring the package's `maxDurationMs` and `requireJustification`, and requiring `iam:bindings:create` on each role and `iam:groups:update` on each group besides `iam:packages:assign`; `packages.revoke` removes exactly what the assignment added; `packages.listAssignments` lists holders. Assignments end with their expiry (`PurgeResult.expiredAssignments`), go with offboarding and deletion, block deleting a packaged role or group or a held package (`RESOURCE_IN_USE`), travel with the configuration document (`packages` by name), and are audited as `package:assign` / `package:revoke`. Console: Access packages page. * Access digest: `iam.sendAccessDigest({ tenantId?, withinMs?, unusedForMs?, minimumIntervalMs? })` (CLI `digest`, a deployment operation like `purge`) emails the owners of every active organization whose access report has findings an `access-digest` message (counts plus the full report as JSON), at most once per interval (20 hours by default) per organization, recorded as `tenant:access-digest`. `groups.addMembers` adds up to 100 members in one transaction with an optional shared expiry; the console group page adds several at once. * Temporary group memberships and future-dated bindings: `groups.addMember({ expiresAt })` and `groups.updateMember({ expiresAt | null })` make a membership end by itself (with every grant and activation it carried); `listMembers` and `identities.listGroups` report `membershipExpiresAt`, re-adding a lapsed member renews it, and the purge worker removes lapsed records (`expiredMemberships`). Bindings accept `startsAt` (`bindings.create`/`update`, `null` clears): the grant is listed with its start but applies only from then. The access report gains `starting` bindings and `expiringMemberships`. Console: "Member until" on the group page, "Starts on" / "Ends on" when assigning a role, and membership expiry on the member page. * Tenant access policy: `tenants.setAccessPolicy({ tenantId, accessPolicy | null })` (`iam:tenants:update`, recent authentication, audited as `tenant:access-policy`) sets organization-wide floors for just-in-time activation: `maxActivationMs` caps every binding, `requireJustification` / `requireMfa` / `requireApproval` apply to every eligible binding, and `approvalLifetimeMs` sets how long requests wait. Bindings can only be stricter. Configuration sync carries `accessPolicy` (`{}` clears it). Console: an "Elevation defaults" card on the Configuration page. * Access report: `reports.access({ tenantId, withinMs?, unusedForMs? })` (`iam:identities:read`; the binding and credential sections need `iam:bindings:read` and `iam:credentials:read` and are named in `omitted` otherwise) returns identities and temporary bindings ending within the window, live activations, the number of pending activation requests, and API keys nobody used or that end soon. CLI `report --tenant ID [--within-days N] [--unused-days N]` prints it as the `BETTER_IAM_TOKEN` holder. Console: a Reports page with clear-deadline, make-permanent, end-activation, revoke, and rotate actions, and an activation history card on the member page. * Approval-gated activation: eligible bindings accept `requireApproval` and `approverGroupId`; `bindings.activate` then records a pending request (lapsing after 24 hours) and emails the approver group (`activation-request`). `bindings.approveActivation` / `bindings.denyActivation` (`iam:bindings:approve` on the role; approver-group members or root when a group is set; never one's own request) make the role live for the requested or a shorter duration, or refuse it, and email the requester (`activation-decided`). `bindings.listApprovals` lists the requests a person may decide on, `listActivations` accepts `status`, `listMine` reports `pendingActivation`, and requesters cancel with `deactivate`. Audited as `binding:activation-requested|approved|denied`. Configuration sync carries `requireApproval` and `approverGroup` by name. Console: the Elevate page shows pending requests, lets approvers decide, and the member page sets the approval rules. * `credentials.create({ scopes })` restricts an API key to an action allowlist (compiled to a session policy; `CredentialSummary.scopes` reports it), and `bindings.list({ expiresBefore })` reports temporary bindings ending before a time. Console: a scopes field when issuing keys. * Tenant policy `requireMfaForOwners` requires a second factor from owners only (their non-MFA sessions stop at the next use; they enroll on the next sign-in). Console: the toggle in settings, a "Change email" card on the account page (`auth.requestEmailChange`), and `/cloud/confirm-email` for the confirmation link; the deliveries page opens `email-change` links in development. * Microsoft Entra ID sign-in: `createOAuthLogin` connections accept `kind: 'microsoft'` with `microsoftTenant` (`organizations` by default, `common`, `consumers`, or one tenant) and an optional sovereign-cloud `issuer`. Multi-tenant connections require `allowedMicrosoftTenants` (Entra `tid` values), and each ID token is validated against the concrete issuer of its own directory. Emails count as verified only with Entra's `xms_edov` claim (protection against "nOAuth" email spoofing). `begin(connectionId, credential, { loginHint, domainHint, prompt })` and the `login_hint` / `domain_hint` / `prompt` query parameters of the start route forward validated sign-in hints (Microsoft `domain_hint`, Google `hd`, GitHub `login`). * Delivery templates and back-off: `renderDeliveryMessage` from `@better-iam/auth/templates` (also `better-iam/auth/templates`, a subpath without native dependencies; re-exported from the main auth entrypoint) renders every built-in outbox template (`verify-email`, `password-reset`, `email-change`, `magic-link`, `code`, `mfa-code`, `new-sign-in`, `owner-invitation`, `member-invitation`) into `{ subject, text, html }` with your own link builders. `RATE_LIMITED` errors carry `retryAfterMs`; HTTP responses add `Retry-After`, and `IamClientError.retryAfterMs` exposes it. The console's deliveries page shows each message's rendered subject. * Role inheritance: `roles.create`/`roles.update` accept `inherits` (role IDs; at most 20, no cycles, no protected roles, empty list clears); a role grants its own policies plus, recursively, the grants of the roles it inherits, each bounded by the inheriting role's authority ceilings as well as the inherited role's. Deleting an inherited role is refused with `RESOURCE_IN_USE`. Configuration sync exports and applies `inherits` by name. Console: an Inherits card on the role page. * Access windows: bindings accept `window: { from, to, timeZone, days? }` (`bindings.create`/`update`, `null` clears); outside the recurring window the binding grants nothing. `identities.listBindings` reports `inWindow`; configuration sync carries windows on group bindings. Console: the window on the member page's role table and assignment form. * Offboarding: `identities.offboard({ tenantId, identityId, reason, successorId? })` (recent authentication, `iam:identities:update`) disables an identity and, in one transaction, revokes its sessions and keys, removes its role bindings (under the caller's authority), group memberships, activations, relationships, and pending access requests, revokes the grant authorities it holds, removes ownership like `setOwner`, and reassigns the managed resources it owns to a successor or reports them. Audited as `identity:offboard` with the reason and counts. Console: an Offboard card on the member page. * CLI `config-plan --fail-on-drift` exits non-zero (`CONFIG_DRIFT`) after printing the plan when the tenant differs from the file, for CI checks. * Outbound SCIM provisioning: `createScimProvisioner({ ...iam.protocolHost, encryptionKey })` from `better-iam/scim` keeps downstream SCIM 2.0 applications in step with a tenant's active members, or with members of chosen groups. It creates users (adopting existing ones by `externalId` or `userName`), pushes changes, and deactivates or deletes (`deprovision`) people who are disabled, deleted, expired, or leave scope. Identity attributes map to `title` and the enterprise extension (`attributeMapping`). Targets are managed through `createTarget`, `listTargets`, `getTarget`, `updateTarget`, and `deleteTarget` (built-in actions `iam:scim:targets:create|read|update|delete|sync`; audited), with encrypted write-only bearer tokens. `syncTarget` runs on demand, `syncAll` suits schedulers, and `subscribe(iam.events)` syncs after member changes. Each run reports created/updated/deactivated/deleted/unchanged/failed counts and failure details in `lastRun`. New collections `provisioningTargets`, `provisioningLinks`, and `provisioningGroupLinks` are removed with their tenant. `pushGroups` also maintains the scoped groups downstream (display name and provisioned members; adopted, updated, or deleted as scope changes). `provisioner.handler` serves the operations as a JSON API (`basePath`, IAM CSRF rule and envelope) for `iam.useProtocol`. Console: an App provisioning page connects applications, scopes them to groups, and syncs, pauses, or removes them. It shows per-run results and failures, and syncs follow member changes automatically. `previewTarget` (and a Preview button) reports what the next sync would change using read-only lookups only. Memberships past their `expiresAt` no longer count toward a target's scope or pushed groups, and access-package assignments (`iam:packages:*`, `package:*` events) trigger a sync. * Passkeys as the second factor: a sign-in that returns `mfaRequired` now reports `passkeyAvailable` when the person has a passkey registered; `auth.beginPasskeyMfa({ tenantId, challenge })` and `auth.finishPasskeyMfa({ tenantId, challengeId, response, rememberDevice? })` complete MFA with a user-verified WebAuthn assertion bound to that login challenge (both challenges are consumed; remembered devices supported). Console: passkey registration and removal on the account page, "Sign in with a passkey" on the login page, and "Use a passkey" at the MFA step (all through `better-iam/client/passkeys`). `tests/support/webauthn.ts` provides a virtual P-256 authenticator for tests. * Tenant-managed SAML connections: with `serviceProvider` (one deployment-wide SP key pair and base URL) and the host's `authorize`, `createSamlService` adds `createConnection`, `listConnections`, `getConnection`, `updateConnection`, and `deleteConnection` (built-in actions `iam:saml:connections:create|read|update|delete` on the new internal resource type `saml`; audited). Organizations enter their IdP's metadata XML, or its sign-on URL, issuer, and certificates, and get fixed `{basePath}/{id}/metadata|acs|login` URLs. The declarative `attributeMapping` feeds identity attributes, summaries report certificate fingerprints and expiry for rollover, and `enabled: false` stops sign-ins. `parseIdpMetadata`, `normalizeCertificate`, and `certificateInfo` are exported, `getMetadata(id)` serves both configured and managed connections, and `samlConnections` is removed with its tenant. IdP-initiated SAML sign-in is available per connection (`allowIdpInitiated`, configured or managed): responses answering no request are fully validated and each assertion ID is accepted once (`samlAssertions`); `idpInitiated(connectionId, samlResponse)` is the direct call and `validateSamlEnvelope` accepts `null` as the expected request. * Nuxt and Vue. New package `@better-iam/vue` (also `better-iam/vue`) for Vue 3.3+. `createIam({ client })` is the plugin, and `useSession`, `useAuthorize`, `useCan`, and `useAccessible` take refs or getters and re-run when the input or the signed-in identity changes. `` has `default`/`fallback`/`loading` slots. Server rendering awaits queries through `onServerPrefetch` and hands the results to the browser through an `IamHydration` store (`createHydration`), so hydration neither refetches nor mismatches. New package `@better-iam/nuxt` is a Nuxt 3.14+/4 module (`betterIam` config key). It mounts the IAM API at `/api/iam/**` in Nitro from the file named by `instance` (default `~~/server/iam`), initializes the instance on first use, and loads the session during SSR through an in-process client bound to each request (`event.context.betterIam`). Page access comes from `definePageMeta({ iam: true | false | { action, resource?, tenantId?, redirectTo? } })` with optional `requireAuth`: 302 to `loginPath?next=` when signed out, and a 403 error page on the server and on client navigation. The module auto-imports `useIamSession`/`useIamClient`/`useIamAuthorize`/`useIamCan`/`useIamAccessible` and `` (sessions typed from the registered instance) and the server utilities `getIamSession`, `requireIamSession`, `requireIamAccess`, `iamCan`, `issueIamAssertion`, `iamCredential`, and `useIam`. `@better-iam/nuxt/h3` exports `createIamH3` for any h3 v1/v2 or Nitro app (per-event session memo, h3-compatible `IamH3Error` with `data.code`, Web or Node request bodies). The framework-agnostic session store moved to `@better-iam/client/session` (`better-iam/client/session`); `@better-iam/react` re-exports it unchanged, and `isUnauthenticated` also recognizes in-process `IamError`s with status 401/403. `examples/nuxt` is a runnable Nuxt 4 app with a production smoke test, and `docs/nuxt.md` is the guide. * Separation of duties: `sod.create/list/update/delete/violations` (built-in actions `iam:sod:read|manage`) declare roles nobody may hold together. Prevent rules refuse, with `SOD_CONFLICT`, any binding, group membership, bulk onboarding, access-request approval, configuration apply, or invitation acceptance that would create a new conflict, while pre-existing conflicts are reported instead of blocking; detect rules only report. The access analysis gains a high-severity `separation-of-duties` finding. Console: a Separation of duties page. * `docs/authentication.md` is a full guide to sign-in methods, MFA (authenticator, recovery codes, emailed codes, remembered devices), sessions and recent authentication, account recovery, tenant authentication policies, impersonation, HTTP cookie behaviour, and the deployment options and email templates. The console gains self-service password recovery: "Forgot your password?" on the login page, `/cloud/reset` to request the email and to choose a new password from its link, and the deliveries page opens `password-reset` links in development. * Configuration as code: the `config` API group (`iam:config:read`, `iam:config:apply`) exports a tenant's roles, policies, groups (with member emails), tenant-defined resource types, and group role bindings as one JSON document keyed by name (`config.export`), computes the creates, updates, and deletes a document implies (`config.plan`, with `prune` for items a listed kind omits), and applies it in a single transaction where every change is authorized like the direct call and one refusal rolls everything back (`config.apply`, audited as `config:apply`). CLI: `config-export`, `config-plan`, `config-apply [--prune]` as the `BETTER_IAM_TOKEN` holder. `validateTenantConfig` and the `TenantConfig`/`ConfigPlan` types are exported; the mutation helpers behind `roles`, `policies`, `groups`, `resourceTypes`, and `bindings` are shared with the API. Console: a Configuration page. * Just-in-time roles: `bindings.create`/`update` accept `eligible`, `maxActivationMs` (default one hour, up to seven days), `requireJustification`, and `requireMfa`; an eligible binding grants nothing until its subject (directly or through a group) calls `bindings.activate` (`iam:bindings:activate` on `iam/{roleId}`), for a bounded time, from an ordinary session, never while impersonating. `bindings.deactivate` ends one's own activation, `bindings.revokeActivation` ends someone else's (like deleting the binding), `bindings.listActivations` lists them, `bindings.listMine` shows members their own roles and what they may activate, and `identities.listBindings` reports `activation`. Activations end with the membership, the binding, or the role, and the purge worker sweeps expired ones (`expiredActivations`). Audited as `binding:activate` (with the justification) and `binding:deactivate`. Console: an Elevate page, eligibility on the member and role pages. * Identity expiry: `Identity.expiresAt` schedules deactivation for contractors and temporary service accounts (`identities.create/createMany/update`, `serviceAccounts.create/update`; `null` clears it). Past the deadline every credential of the identity is refused (`UNAUTHENTICATED`), `identities.list({ expiresBefore })` reports upcoming expiries, and the purge worker disables such identities, revokes their sessions, and records `identity:expire` (`expiredIdentities`); re-enabling requires clearing or extending the deadline first. The last owner cannot be scheduled away. Console: the deadline on the member page and when creating service accounts. * API key hygiene: keys carry a `name` and `description` (`credentials.create`, `credentials.update`, which can also move the expiry within a year under recent authentication), `credentials.get` and `credentials.list` return `lastUsedAt` (recorded at most once a minute when the key authenticates a request) and `credentials.list({ unusedForMs })` finds keys nobody has used, including never-used ones; rotation keeps the label and starts the usage history over. Console: labeled key issuance and an "unused 30d" flag. * `@better-iam/adapter-sqlite`: closing a store before its first query now releases the database file (Kysely opens the driver lazily, so the eagerly opened connection was left to garbage collection). * Emailed MFA codes: `authentication.mfaEmailCodes` and the tenant policy field `mfaEmailCodes` let people without an authenticator satisfy an MFA requirement with a one-time code (`auth.requestMfaCode` for a login challenge whose sign-in reported `emailCodeAvailable`, then `verifyMfa`); codes are hashed, single-use, bound to the challenge, and expire with it. Never offered to root administrators or to people with an authenticator. Console: "Email me a code instead" on the MFA step and the policy toggle in settings. The console's IAM route now answers `GET`, so `/api/iam/health` and `/api/iam/metrics` work behind Next.js. * CLI `analyze --tenant ID [--dormant-days N] [--fail-on high|medium|low]` prints access-analysis findings as the `BETTER_IAM_TOKEN` holder and exits non-zero at the chosen severity, for nightly jobs and deployment gates. * Access certification campaigns: `certifications.create/list/get/decide/close/delete` (built-in actions `iam:certifications:read|review|manage`) snapshot a tenant's role bindings for designated reviewers, forbid self-review, and on close remove revoked (optionally undecided) bindings under the closer's authority, recording each item's outcome. Console: a Certifications page with per-binding keep/revoke buttons. * Network allowlists: the tenant authentication policy gains `allowedIpRanges` (IPv4/IPv6 addresses or CIDR blocks); sessions, including impersonation, are refused with `IP_NOT_ALLOWED` when the recorded client IP is outside them, and existing sessions from outside stop working at their next use. `@better-iam/core` exports `isIpRange` and `ipMatches`. The console settings page edits the list, and the admin panel gains an Operations page (health, outbox backlog, active sessions, and the process's Prometheus counters and latency histograms; the console enables `observability.metrics` and exposes `GET /api/iam/metrics` when `METRICS_TOKEN` is set). * `@better-iam/next` App Router integration: `iamNext.client()` is the typed API bound to the current request, calling the IAM handler in process and writing the cookies it issues through `cookies()`, so server actions sign people in and out (and through MFA) without client JavaScript. `page(render, spec)`, `route(handler, spec)`, and `action(fn, spec)` wrap pages and layouts, route handlers (JSON error envelope with the server's status; cookie or bearer API key), and server actions (`ActionResult` for `useActionState`; Next control flow propagates) with authentication and optional `authorize: { action, resource, tenantId }`. The ambient `getSession()` is memoized per request with React `cache` (`cache` option), reads cookies a server action just set, and `sessionForClient()` returns JSON for `IamProvider initialSession`. `interrupts: true` uses Next's `unauthorized()` / `forbidden()`; `requireTenantSession({ slug })` / `tenant(slug)` serve `/[org]/...` routes (unknown aliases call `notFound()`, other organizations go to `/login?org=`); `handlers()` adds `GET` for `/health` and `/metrics`. Middleware gains `publicPaths` globs, `signedInRedirect` (bare login visits only; guards add `?next=` when they reject a stale cookie, so it cannot loop), and `next` (pass `NextResponse.next`) to forward `x-better-iam-pathname`, which becomes the default `?next=`. The new edge-safe `@better-iam/next/edge` subpath (`better-iam/next/edge`) holds the middleware plus Web Crypto `verifyAssertionToken` / `withAssertion` (offline assertion checks for downstream services), `verifyWebhook` / `createWebhookHandler` (signed webhook receiver with secret rotation, freshness, size limits, and retry-friendly failures), and `safeRedirectPath` against open redirects. The server instance reports its HTTP `endpoint` (`origin`, `basePath`, `secure`). `docs/nextjs.md` is a full guide, and `examples/nextjs` is a runnable App Router application covering each helper. * `@better-iam/next` Pages Router and client components: `iamNext.pages.withSession(gssp, spec)` (`getServerSideProps` with login redirects, `notFound` or redirect on denial, and the session as JSON props), `pages.api(handler, spec)` (API routes with the JSON error envelope), `pages.client(req, res)` (in-process client appending cookies to the response), `pages.getSession(req)`, and `pages.handler()` for `pages/api/iam/[...path].ts` (accepts bodies Next already parsed). `interrupts: 'forbidden'` interrupts denials only and keeps login redirects. The new `'use client'` entry `@better-iam/next/client` (`better-iam/next/client`) adds `IamNextProvider` / `useRouterSync()` (calls `router.refresh()` when the signed-in identity changes in the browser) and `useSignOut({ redirectTo })`. `iamNext.allowed(action, resource?)` and the async server component `` batch every check made during a render into one deduplicated `authorizeMany` per tenant (50 per call) and reuse answers for the rest of the request. * New package `@better-iam/nestjs` (also `better-iam/nestjs`) for NestJS 11 and 12 on Express or Fastify. `IamModule.forRoot` / `forRootAsync` provides `IamService` (`principal`, `authorize`, `require`, `can`, `listAccessible`, `assertion`, all per request) and `IamGuard`. Options install the guard globally (`guard`), serve the IAM HTTP API from the Nest app (`mount`, which also handles bodies Nest has already parsed), and register `IamExceptionFilter` so `IamError`s keep their status and `{ error: { code, message } }` body. Decorators: `@Public`, `@Authorize(action, { resource, tenant })` (rules add up across class and method; values come from params, query, body, headers, GraphQL arguments or WebSocket message fields, or functions), `@RequireMfa`, `@Credentials('api-key')`, `@CurrentPrincipal` / `@CurrentIdentity` / `@CurrentSession` / `@TenantId`, and `@OnIamEvent(pattern)` for provider methods (with optional `dispatchIntervalMs`). The guard rejects cookie-authenticated unsafe requests from foreign origins (`csrf`) and supports HTTP, GraphQL and WebSocket contexts. Downstream services use `IamAssertionModule.forRoot({ key, audience })` with `IamAssertionGuard`, `@AssertionClaims()` and `@RequireClaims({ roles, groups, mfa, kinds })`. That path loads only the new `@better-iam/server/assertions` subpath, so a verifying service doesn't need the server's native dependencies. `@FilterAccessible(action, { type, id?, path? })` trims list responses to the resources the caller may act on through the `listAccessible` reverse query, so filtering doesn't write an audit denial per item. `forRootAsync` also takes `useClass` / `useExisting` (`IamOptionsFactory`). `IamService.health()` probes the IAM database in process, and `IamService.credential(request)` returns the caller's credential. The mount path defaults to the server's reported `endpoint.basePath`. `examples/nestjs` is a runnable Express app with a smoke test (`pnpm --filter @better-iam/example-nestjs smoke`). `@better-iam/nestjs/testing` (`better-iam/nestjs/testing`) exports `createTestingIam`, an in-memory stand-in for tests: principals chosen by bearer token, decisions made by a callback, registered resources for `@FilterAccessible`, a `decisions` log, and `emit()` for `@OnIamEvent` handlers. * OAuth authorization server: client management (`listClients`, `getClient`, `updateClient`, `rotateClientSecret`; new built-in action `iam:oauth:clients:update`). Narrowing a client's grant types, scopes, or resources, or requiring DPoP, revokes everything issued to it. Connected apps: `listGrants`, `revokeGrant`, `revokeGrants` (self-service, or `iam:oauth:grants:read|revoke` for other accounts; audited `iam:oauth:RevokeGrant`), and repeated consent in one provider session extends a single grant. Resource servers: the `resourceServers` option (RFC 8707 resource indicators) issues audience-restricted JWT access tokens (RFC 9068) to clients registered with `resources`. The new `createAccessTokenVerifier` verifies them offline, including DPoP proofs (RFC 9449) with replay detection. Clients can set `requireDpop` / `requirePushedAuthorization`, the provider takes `requirePushedAuthorizationRequests` / `dpopNonceSecret`, and `interactionDetails` adds `clientName` and `resources`. Confidential clients can authenticate with `client_secret_post` or `private_key_jwt` (`tokenEndpointAuthMethod`, with `jwks` / `jwksUri`; single-use assertions, key rotation through `updateClient`). OpenID back-channel logout: clients register `backchannelLogoutUri`, and `logoutEndedSessions()` notifies them and revokes consents whose IAM session ended (audited `iam:oauth:SessionLogout`). Outbound provider requests allow loopback targets only under `allowInsecureLocalhost`. Per-client `accessTokenTtl` / `refreshTokenTtl` can shorten token lifetimes below the provider and resource server defaults. Clients carry consent-screen branding (`logoUri`, `clientUri`, `policyUri`, `tosUri`) and a `firstParty` flag, reported by `interactionDetails(...).client`. Token exchange (RFC 8693): confidential clients registered for `urn:ietf:params:oauth:grant-type:token-exchange` trade an account's access token for a token to one of their resources, with the client as `act` and a lifetime capped by the subject token. `authorizeTokenExchange` adds policy, exchanges are audited as `iam:oauth:TokenExchange`, and the verifier exposes `actor`. Consent now grants requested resource scopes, so browser flows with `resource` receive resource-server tokens. * Access analysis: `analysis.findings` (`iam:analysis:read`) reports unrestricted administrator policies, service-wide action wildcards, administrators without a second factor, service accounts with full administration, dormant members who still hold access, trusts without MFA, and unattached policies, unused or empty roles, and member-less groups with bindings, ordered by severity with deterministic IDs; `analysis.suppress` / `unsuppress` (`iam:analysis:update`) record accepted risks. Console: a Security findings page. * Verified domains and home-realm discovery: a new `domains` API group (`add`, `list`, `verify`, `delete`; built-in actions `iam:domains:create|read|update|delete`) lets an organization prove control of an email domain with a DNS TXT record; each domain is verified by at most one tenant and consumer mailbox providers are refused. Public `domains.discover({ email })` returns the owning tenant with its alias, accepted sign-in methods, and MFA requirement. The `domains` option injects the TXT resolver, record label, and blocked list. Console: a Domains page and "use your work email" on the organization picker. * Security activity and sign-in alerts: `auth.listSecurityEvents` returns the caller's own `auth:*` audit trail (impersonators named) for account pages; `authentication.signInNotifications` and the tenant policy field `notifyNewSignIn` queue a `new-sign-in` email when a session starts from a client (user agent + IP) that none of the person's live sessions or remembered devices has used. Console: "Recent security activity" on the account page and the alert toggle in settings. * Password policy: tenant authentication policies gain `passwordHistory` (refuse the last 1–24 passwords; `PASSWORD_REUSED`), `passwordMaxAgeDays` (a verified but expired password is refused with `PASSWORD_EXPIRED` until reset), `passwordMinClasses` (2–4 character classes), and `passwordRejectPersonalInfo`. `authentication.passwordPolicy` adds deployment-wide screening: a built-in common/sequential/low-variety screen (on by default), `isBreached` (`BREACHED_PASSWORD`; `pwnedPasswords()` is a k-anonymity Have I Been Pwned client), and a custom `check`. Identities record `passwordChangedAt`; previous hashes live in the new `passwordHistory` collection (newest 24, removed with the identity or tenant). `@better-iam/auth` exports `pwnedPasswords`, `isCommonPassword`, and `characterClasses`. Console settings edit the new rules. * Trusted devices ("remember this device"): `verifyMfa` and `confirmMfa` accept `rememberDevice` and return a `deviceToken` (with `deviceExpiresAt`) that lets the same browser skip MFA on later `signIn` / `finishPasswordless` calls; `authentication.trustedDeviceLifetimeMs` caps the deployment (30 days by default, 0 disables) and the tenant policy gains `trustedDeviceDays`. Root administrators are never remembered; password, email, and factor changes forget every device; sessions record `trustedDeviceId`; `auth.listTrustedDevices`, `revokeTrustedDevice`, and `revokeTrustedDevices` manage them (audited as `auth:device:trust` / `auth:device:revoke`). The HTTP handler keeps the token in a `better-iam.device` cookie and injects it into sign-in bodies. Console: a "remember this device" checkbox at MFA, a remembered-devices card on the account page, and the policy field in settings. * SCIM protocol coverage: RFC 7644 filters (`and`/`or`/`not`, grouping, value paths such as `emails[type eq "work"]`, sub-attributes, schema-qualified enterprise attributes, `gt/ge/lt/le`) evaluated against rendered resources; `sortBy`/`sortOrder`; `attributes`/`excludedAttributes` projection; `POST {Users|Groups}/.search`; `POST /Bulk` (100 operations, per-operation transactions, `bulkId` forward references, `failOnErrors`); `If-None-Match`. PATCH gains sub-attribute and value-path targets (`name.givenName`, `emails[type eq "work"].value`, `urn:…:enterprise:2.0:User:department`), listed-member removal and Entra-style `"True"`/`"False"` booleans, and no longer drops `title` or the enterprise extension on unrelated changes. `scim.listConnections` reports usage (`lastUsedAt`, user/group counts) and `scim.rotateToken` replaces a connection's token without losing provisioned state. Discovery advertises bulk, sorting and the enterprise extension schema. * Operations endpoints: `observability.metrics` keeps Prometheus-style counters and histograms from spans (`iam.metrics.render()` / `snapshot()`, exported `createMetrics`), served at `GET {basePath}/metrics` when a `bearerToken` is configured; `GET {basePath}/health` reports database reachability. Caller-controlled names collapse and series are capped, so scrapes stay bounded; `gauges: true` adds outbox-backlog and live-session gauges read from storage on each scrape. The console audit log gains a search form (action glob, actor, resource, outcome, time range) with pagination and actor names. * Impersonation ("view as"): `identities.impersonate` opens a member session for support when the tenant's authentication policy sets `allowImpersonation` (new built-in action `iam:identities:impersonate`, recent authentication, a recorded reason, at most eight hours and never beyond the administrator's own session). Owners, root administrators, service accounts, and the caller are never eligible. Such sessions carry `impersonatorId`, cannot perform recent-authentication operations, re-authenticate, assume roles, grant OAuth consent, or impersonate further, end with the administrator's session (`auth.endSession`), and are excluded from `maxSessions`. Audit events and webhook bodies gain `impersonatorId`, policies gain `principal.impersonated` / `principal.impersonatorId`, assertions gain `impersonatorId`, `AuthMethod` gains `impersonation`, and the HTTP layer never sets a cookie for the impersonation token. Console: settings toggle, "View as" on the member page, a banner with a stop button, and attribution in the audit log and session lists. * Tenant authentication policy gains `maxSessions` (concurrent sessions per person; the oldest ends). `identities.requestPasswordReset` queues a reset email for a member on an administrator's behalf. `docs/api-reference.md` is generated from a live instance by `pnpm docs:api`; the HTTP route tables (`routeGroups`, `publicApiMethods`, `publicAuthMethods`, `authenticatedAuthMethods`) are exported from the server package. * `tenantDefaults` option: plan limits and an authentication policy applied to every tenant created with `tenants.create`, validated at construction. `tenants.resendInvitation` and `identities.resendInvitation` renew an invitation's token and lifetime and queue the email again; the console offers both. * Tenant authentication policy gains `maxAttempts` (tighter rate limits for the tenant's authentication flows) and `minPasswordLength` (enforced on creation, reset, and change). `identities.update` accepts `email` for administrator-driven address changes (recent authentication, unverified, sessions revoked, audited as `identity:email-change`). * Federated attributes: OAuth/OIDC sign-in connections and SAML connections accept `mapAttributes`; mapped values are validated against `permissions.identityAttributes` and stored on the identity at every sign-in (`FederatedLogin.attributes`). * Audit retention: `iam.pruneAudit({ tenantId, retentionMs })` and CLI `audit-prune` delete a tenant's oldest events behind an `audit:prune` checkpoint that keeps the chain verifiable. * Access reviews accept platform resources (`iam/...`) as the reviewed resource. * OAuth/OIDC provider: the built-in `iam` scope adds `roles`, `groups`, and `attributes` claims (live when read, expired bindings excluded) to userinfo. * `apps/console`: member search and pagination on the members page. * Directory: `identities.list` accepts `query` (name or email, case-insensitive), `limit`, and `offset`, ordered by name; `identities.listSessions` returns a member's active sessions for administrators (`iam:identities:read`), without token material. * `docs/recipes.md` collects copy-ready examples for sharing, reviews, policy testing, tenant auth policies, audit archives, assertions, observability, incident response, exports, limits, webhooks, bulk onboarding, and libSQL. * Policy tooling: `policies.restoreVersion` rolls a policy back to an earlier version (re-validated, kept in history) and `policies.test` evaluates a candidate document against an action, resource, and supplied context without storing it; the console policy page offers both. * Account unlock: `identities.unlock` clears the sign-in, recovery, and MFA rate-limit counters of an identity (recent authentication, `iam:identities:update`, audited as `identity:unlock`). `RateLimiter.reset` is optional for custom limiters; the built-in limiters implement it. * SCIM: users carry `title` and the enterprise extension (`department`, `division`, `manager`, …); `mapAttributes` turns them into declared identity attributes, validated through `protocolHost.validateIdentityAttributes`. * Plan limits and usage: root sets `tenants.setLimits` (members, service accounts, groups, roles, policies, resources, webhooks; audited as `tenant:limits`); every creation path, including invitation acceptance, self-registration, federation, SCIM, and bulk creation, fails with `LIMIT_EXCEEDED` past a limit. `tenants.usage` reports counts, active sessions, and limits. The admin panel edits limits and both consoles show usage. * Webhook filters: subscriptions accept `outcomes` and `resources` (glob patterns over the resource ID) in addition to action patterns. * Bulk onboarding: `identities.createMany` creates up to 100 identities atomically with optional passwords, declared attributes, roles (authorized like invitations, bound under the caller's grant authority), and groups. * Webhook redelivery: outbox messages remember the audit event behind a delivery (`reference`; `listDeliveries` exposes `eventId`) and `webhooks.redeliver` queues that event again, rebuilt from the audit record and signed with the current secret. The console offers it per delivery. * `@better-iam/next`: `iamNext.assertion({ tenantId, audience, ttlSeconds?, claims? })` issues a stateless assertion for the current request's session. * `apps/console`: member attributes (department, title) editable on the member page; the admin audit page reports chain integrity. * Data-subject export and incident response: `identities.export` returns everything a tenant stores about one identity (no secrets; audit trail when the caller may read it), audited as `identity:export`; `identities.revokeSessions` ends one identity's sessions without disabling it; `tenants.revokeSessions` ends every session in a tenant (`includeSelf` optional). All three require recent authentication. The console offers them on the member page and in organization settings. * New package `@better-iam/adapter-libsql` (`better-iam/adapter-libsql`): libSQL persistence through `@libsql/client` for local files, encrypted files, embedded replicas, and remote Turso or sqld databases, with the same schema, transaction guarantees, and error mapping as the SQLite adapter. `better-iam init --database libsql` scaffolds it. * Session metadata: sessions issued through the HTTP handler record `client.userAgent`; `http.clientInfo(request)` derives `ip`, `userAgent`, and a device `label` behind a trusted proxy; `iam.auth.withClient(info, fn)` scopes details for direct calls. `auth.revokeOtherSessions` ends every other session of the caller (recent authentication required, audited as `auth:session:revoke-others`); the console lists devices and offers "Sign out other sessions". * CLI: `audit-verify --tenant ID` recomputes a tenant's audit chain from storage (non-zero exit when broken) and `audit-export --tenant ID --output FILE` writes it as JSON Lines; `doctor` reports chained tenants and events. * `apps/console`: workspace sharing through relationships (workspace page and member page), an access-reviews page (who can, effective actions), audit chain verification and JSONL export, the organization authentication policy in settings, relations on resource types, and the sign-in method on sessions. * Observability: `observability.onSpan` receives a timed span for every provisioning operation, `authorize`/`authorizeMany`/`listAccessible` query, authentication call, and HTTP request, with tenant, outcome (`ok`, `denied`, `error`), error code, status, and duration. Handler failures are ignored. * Stateless assertions (`assertions` API group): `assertions.issue({ tenantId, audience, ttlSeconds?, claims? })` returns a short-lived HS256 JWT describing the caller for a service, authorized as `iam:assertions:create` on `iam/{audience}`. `iam.assertionKey()` derives the verification key from the deployment secret and `verifyAssertion(token, { key, audience, issuer? })` checks tokens offline. * Tenant authentication policies: `tenants.setAuthPolicy` sets or clears `requireMfa`, `allowedMethods`, `sessionLifetimeMs`, and `sessionIdleTimeoutMs` per tenant (recent authentication and `iam:tenants:update`; audited as `tenant:auth-policy`). Policies only tighten the deployment's configuration; method restrictions are enforced before credentials are examined (`METHOD_NOT_ALLOWED`), sessions are re-validated on use, and MFA cannot be disabled while required. User sessions record their sign-in `method`, exposed to policies as `principal.authMethod`. `iam.auth.mfaRequired`, `tenantRequiresMfa`, and `sessionLimits` are public for trusted integrations. * Audit hash chain: every audit event carries `sequence`, `previousHash`, and `hash` (SHA-256 over canonical JSON) linked per tenant through `auditChains`; all writers (server, authentication, SCIM, OAuth provider) append through `appendAuditEvent`. `audit.verify` checks a tenant's chain or a window of it, `audit.export` pages events as JSON Lines, and `verifyAuditChain`/`auditEventHash`/`canonicalJson` (core and `better-iam`) verify archives offline. `initialize()` backfills unchained events once. Webhook bodies include `sequence` and `hash`. * Access reviews under `iam:policies:simulate`: `policies.whoCan` lists the identities that could perform an action on a resource (with `kind`, `assumeMfa`, and pagination), `policies.effectiveActions` lists the actions an identity holds on a resource across the catalog or a chosen list, and `policies.simulate` accepts `assumeMfa`. * Relationships (`relationships` API group): resource types declare `relations` (in `permissions.resourceTypes`, plugin `resourceTypes`, and `resourceTypes.register/update`); `relationships.create/list/delete` bind identities or groups to one resource under a declared relation with optional expiry, authorized by `iam:relationships:create/read/delete` on `iam/{type}/{id}`. Evaluation exposes the principal's live relations on the resource as `resource.relations` and on its registered parent as `resource.parentRelations`; `iam/{type}/{id}` administrative checks on registered managed resources now carry that resource's attributes, owner, parent, and relations. Tuples are removed with their identity, group, or resource, and a relation still in use cannot be dropped from its type. * Condition operators: `StringNotEquals`, `StringEqualsIgnoreCase`, `StringNotEqualsIgnoreCase`, `StringNotLike`, `StringLikeIgnoreCase`, `NumericNotEquals`, `NumericLessThanEquals`, `NumericGreaterThanEquals`, `NotIpAddress`, `ArrayContains`, and `ArrayContainsAll`. Negated operators and `ArrayContainsAll` require every listed value; missing or wrongly typed context never satisfies any operator. * Principal context: `principal.kind`, `principal.owner`, `principal.rootAdmin`, `principal.sessionKind`, `principal.groups`, and `principal.roles` are available to conditions and variables. `permissions.identityAttributes` declares typed attributes that `identities.update` and `serviceAccounts.update` set and policies read as `principal.{name}`; `Identity.attributes` and `Identity.description` are part of the public identity. * Plugin contract: plugins may contribute `resourceTypes`, `hooks.beforeOperation`/`afterOperation` (run inside the operation transaction; throwing aborts it), and `resolveContext`; plugin endpoints receive `deliver` to queue email/SMS through the host outbox. * Policy variables: `${principal.id}` and any other trusted context key can appear in resource patterns (after the type segment) and in `StringEquals`/`StringLike` values. Substituted values match literally, unresolved variables never match, and malformed references are rejected at validation. * Reverse queries: `iam.listAccessible` / `client.listAccessible` return the registered resources of a managed type that the caller may perform an action on, evaluating grants once per query. `resources.registerMany` registers up to 100 resources atomically with per-item authorization. * Temporary bindings: `bindings.create` accepts `expiresAt`; expired bindings grant nothing and are hidden from effective views (`bindings.list` has `includeExpired`); `bindings.update` extends, shortens, or clears the expiry. `purgeDeleted` now also removes expired bindings and expires stale access requests, and reports `expiredBindings`/`expiredRequests`. * Access requests (`accessRequests` API group): members with `iam:access-requests:create` request roles with a justification and optional duration; reviewers with `iam:access-requests:review` approve (creating bindings under their own grant authority, subject to `iam:bindings:create` on each role) or deny; requesters cancel; pending requests expire after `accessRequests.lifetimeMs` (default 7 days). * Events and webhooks: every audit event is now an event. `iam.events.subscribe(pattern, handler)` and `events.onEvent` receive committed events from the dispatcher (`iam.events.dispatch`, alias of `dispatchAuditHooks`, now returns `{ dispatched }`). The `webhooks` API group subscribes HTTPS endpoints per tenant (root may subscribe a subtree) to event patterns; deliveries are queued in the same transaction as the audit record, signed with a per-subscription secret (`X-Better-IAM-Signature`, verify with `verifyWebhookSignature`), retried with exponential backoff, and listed through `webhooks.listDeliveries`. `events.deliverWebhook` replaces the built-in HTTPS transport. * Outbox: `dispatchOutbox` returns `{ delivered, failed, abandoned }`, retries with backoff (30 seconds doubling to one hour), abandons messages after `authentication.maxDeliveryAttempts` (default 25) with `failedAt`/`lastError`, and dispatches in creation order. * Configurable rate limits: `authentication.rateLimits` sets `attempts` (default 10), `sensitiveAttempts` (default 5), `windowMs` (default 15 minutes), and a pluggable `limiter`; `createMemoryRateLimiter()` is provided for single-process deployments and tests. * Administration fills: `identities.delete` (tombstones the identity, revokes credentials, factors, bindings, memberships, authorities, and links; `identities.list` gains `kind`, `status`, and `includeDeleted` filters), `serviceAccounts.list/get/update/setStatus/delete` with an optional `description`, `credentials.list` (`iam:credentials:read`), `trust.list` (`iam:trust:read`), `root.listAdministrators`, and `audit.list` filters (`actorId`, glob `action`, `resourceId`, `outcome`, `from`, `to`) with newest-first ordering. `Identity.status` gains `deleted`. * Internal restructure with no public API change: the server is split into focused modules (`options`, `catalog`, `context`, `events`, `decisions`, `principals`, `operations`, `flows`, `lifecycle`, `federation`, `http`, and one file per API group), the authentication service is assembled from feature classes over a shared base with `outbox` and `rate-limit` modules, SCIM separates its filter, discovery documents, provisioning, and handler, the OAuth provider adapter lives in its own module, `tenantTreeActive` in core replaces four private tenant-tree walkers, and the whole repository is formatted with Prettier. * New packages: `@better-iam/react` (`IamProvider`, `useSession`, `useAuthorize`, `useAccessible`, `Can`, and the framework-agnostic `createSessionStore`) and `@better-iam/next` (`createIamNext` with `getSession`, `requireSession`, `require`, `can`, `handlers`, plus `createIamMiddleware`), exposed as `better-iam/react` and `better-iam/next`. * `apps/console`: a Next.js administration panel (`/admin`, root administrators with MFA: organizations, root administrators, catalog, audit, deliveries) and multi-tenant cloud console (`/cloud`, alias sign-in, invitations, workspaces as managed resources, members, roles, groups, policies, resource types, resource registry, service accounts and API keys, account settings with sessions/MFA/linked accounts). Built on `iam.api.*` from server components, the typed client through `/api/iam`, and `iam.require` for enforced pages. * Fixed `SafeIdentity`/`SafeSession` (returned by `getSession`, `listSessions`, `groups.listMembers`) losing their known properties: `Omit` over the record index signature erased them; they are now mapped types. * Resource catalog: `permissions.resourceTypes` declares application-owned and IAM-managed resource types with actions, typed attributes, and parents; managed resources are registered through the new `resources` API and resolved without an application callback. Tenants in `tenant-defined` mode register their own managed types and `{type}:{verb}` actions through the `resourceTypes` and `actions` APIs. Policies, inline role documents, boundaries, ceilings, and session policies are validated against the catalog (`INVALID_ACTION`, `INVALID_RESOURCE_TYPE`). * Breaking: tenant-defined actions no longer use the `tenant/{tenantId}/` prefix; `actions.list` returns `{ name, source, resourceType, description }` objects. Added `actions.unregister`. * Roles: `roles.create`/`update` accept `description`, an inline `document`, or a `permissions` list; added `roles.get`, `roles.listBindings`, `bindings.list`, `bindings.read` action, `identities.listBindings` (effective roles through groups), `identities.listGroups`, `identities.get`, `identities.update`, `groups.get`, `groups.update`, `groups.listMembers`, `policies.get`, `policies.listVersions`, and policy/group descriptions. `policies.update` accepts name/description changes. * Organizations as accounts: optional globally unique tenant `slug` on `tenants.create`, `bootstrap`, and `tenants.setSlug`; public `tenants.lookup` resolves an active tenant for alias-based sign-in. Member invitations (`identities.invite`, `listInvitations`, `revokeInvitation`, public `acceptInvitation`) onboard people into an existing tenant with roles and groups applied under the inviter's authority. `identities.create` no longer requires a password. `links.list` returns linked accounts for account switchers. * `iam.authorizeMany` and `client.authorizeMany` evaluate up to 50 checks in one transaction for UI state. Invitation responses no longer include token hashes. * Application-level integration test (`tests/application.test.ts`) exercising the HTTP handler and typed client end to end, with compile-time inference checks; the example application demonstrates aliases, invitations, permission roles, resource types, and managed workspaces. * Tenant lifecycle administration: rename and move tenants with hierarchy, depth, cycle, and grant-authority validation; list and revoke owner invitations; delete pending tenants; retention purging through `iam.purgeDeleted` and the CLI `purge` command, with audit records preserved. * `@better-iam/projects` reference plugin: tenant-scoped project records through `projects:read`/`projects:write` plugin endpoints (create, list, get, update, archive, restore), including purge cleanup of purged tenants' records. ## 0.1.0 [#010] Initial independent authentication and IAM implementation: tenant hierarchy, isolated identities, policies and delegation, root authority, service credentials, role assumption, password/MFA/passkey flows, federation/provisioning packages, typed client, CLI, SQL adapters, tests, examples, and synchronized packaging. # CLI (/docs/reference/cli) > The better-iam command line: what each of its 47 commands does, when to run it, and its flags. The `better-iam` command runs the work that does not belong in a web request: creating and upgrading the schema, creating the first administrator, health checks, the scheduled jobs that deliver, expire, remind, reconcile, and clean up, audit verification and archiving, configuration as code, and moving a deployment between databases. It is the `bin` of `@better-iam/cli`, and `runCli(argv)` from the same package (also exported as `better-iam/cli`) runs a command from your own scripts. Every command that touches the deployment loads your application configuration: `--config`, else `BETTER_IAM_CONFIG`, else the nearest `better-iam.config.mjs` (or `.js`, `.ts`, `.mts`, `.cjs`) in the working directory or a parent, else `BETTER_IAM_DATABASE_URL` and `BETTER_IAM_SECRET` with no file at all. The file is a module whose default export is the options you pass to `betterIam()`, a function (sync or async) that returns them and receives `{ command, env, cwd }`, or an instance you already created. It may also export `cli = { defaults }` (flag defaults per command) and `commands` (project commands built with `defineCommand`). It runs as JavaScript with access to your database and secrets, so point it only at trusted files, and give the CLI the same environment (`BETTER_IAM_SECRET`, database location, `BETTER_IAM_PREVIOUS_SECRETS`) as your application. Commands come in three kinds: * **Deployment operations** (`migrate`, `bootstrap`, `recover-root`, `doctor`, `outbox`, `purge`, `sweep`, `digest`, `remind`, `reconcile`, `close-certifications`, `monitor-invariants`, `audit-verify`, `audit-export`, `audit-prune`, `audit-archive`, `rotate-secrets`, and the `store-*` commands) need no credential. They work on storage with the deployment's authority, and the ones that change access record audit events as `deployment-operator`. Whoever can run them with your configuration holds the database and the secret, so protect that configuration like a root credential. * **Member commands** (`config-export`, `config-plan`, `config-apply`, `analyze`, `report`, `mine-roles`, `check-invariants`, `whoami`, `api`, `can`, `explain`, and `who-can`) act as the session token or API key in `BETTER_IAM_TOKEN`, or the session saved by `login` when it is unset. They run in process through the configuration or against a running server with `--url` (`BETTER_IAM_URL`), and read `--tenant` from `BETTER_IAM_TENANT` or the saved session. They are authorized and audited exactly like the same call from the console, so use the API key of a service account whose role holds only the permissions the command needs. * **Offline commands** (`init`, `secret`, `config-validate`, `audit-verify-archive`, `profiles`, and `completion`) touch no database. Results are printed as indented JSON on standard output (`purge`, `outbox`, `audit-prune`, and `audit-export` keep their one-line result); `--format compact` prints one line, `--format json` indents, `--format table` aligned columns, and `--query PATH` (`summary.create`, `findings[].kind`) only part of the result. Every flag also takes the `--flag=value` form, and `better-iam help ` lists a command's flags with their defaults and environment variables. A failure prints `CODE: message` on standard error, often followed by a `Hint:` line naming the next step (a generic message for errors that are neither Better IAM nor system errors; `BETTER_IAM_DEBUG=1` shows them), and exits with status 2 for a command-line mistake and 1 otherwise. Unknown commands fail with `INVALID_COMMAND`, flags a command does not take with `INVALID_ARGUMENT`, and member commands without a token with `MISSING_ENV`. Several commands also exit 1 on purpose when they find something, for CI and alerting: `doctor --strict` (`DOCTOR_FINDINGS`), `config-plan --fail-on-drift` (`CONFIG_DRIFT`), `analyze --fail-on` (`FINDINGS`), `check-invariants --fail-on-broken` (`INVARIANTS_BROKEN`), `reconcile --fail-on-attention` (`RECONCILE_ATTENTION`), `audit-verify` (`AUDIT_CHAIN_BROKEN`), `audit-archive` (`AUDIT_ARCHIVE_FAILED`), `audit-verify-archive` (`AUDIT_ARCHIVE_INVALID`), `rotate-secrets` (`UNREADABLE_SECRETS`), `config-validate --strict` (`CONFIG_WARNINGS`), and `can` (`ACCESS_DENIED`). Each of them prints its JSON result first, so the job log keeps the details. npm pnpm yarn bun ```bash npx better-iam migrate ``` ```bash pnpm dlx better-iam migrate ``` ```bash yarn dlx better-iam migrate ``` ```bash bun x better-iam migrate ``` > **Secrets never go on the command line.** Bootstrap and recovery read `BETTER_IAM_ROOT_EMAIL`, `BETTER_IAM_ROOT_NAME`, `BETTER_IAM_ROOT_PASSWORD` from the environment, and `login` reads the password from `BETTER_IAM_PASSWORD` or a hidden prompt. Commands that act as a member (configuration, analysis, reports, `api`, `can`) use the session token or API key in `BETTER_IAM_TOKEN`, or the session saved by `login`, in process or against `--url`, and are authorized and audited like console operations. ## Scheduling the job commands [#scheduling-the-job-commands] Better IAM starts no background work of its own. Messages wait in the outbox, expired access waits for the purge, and package rules wait for reconciliation until something runs the matching command, or the matching instance function from a worker in your application (see [Scheduled jobs](/docs/operations/jobs)). Every job below is safe to rerun and to overlap with itself, and `doctor` reports the ones that have stopped running. | Command | Cadence | Why | | ------------------------------------------------------------------ | ------------------------------------------ | --------------------------------------------------------------------------------------------------- | | `outbox` | Every minute | Delivers email, SMS, and webhooks. `doctor` warns when messages wait more than 15 minutes. | | `audit-archive` | Every few minutes, at least hourly | Keeps the independent audit copy current. `doctor` warns about unarchived events older than a day. | | `reconcile` | Every 15 minutes, after `purge` | Rule-based access packages only see SCIM, invitation, attribute, and group changes through it. | | `purge` | Hourly, at least daily | Ends expired access and removes deleted tenants. `doctor` warns when expired records are a day old. | | `sweep` | Hourly or daily, beside `purge` | Stops storage growing with traffic. `doctor` warns about records due for more than two days. | | `monitor-invariants` | Hourly, and after configuration changes | Records `invariant:broken` and `invariant:restored` for webhooks to alert on. | | `close-certifications` | Hourly or daily | Applies auto-closing certification campaigns once they are due. | | `digest`, `remind`, then `outbox` | Daily | Emails owners and people about access that ends soon. | | `audit-prune` | Per your retention policy, after archiving | Deletes each tenant's old audit events. | | `report`, `analyze --fail-on` | Nightly | Member jobs for a ticket, chat channel, or alert. | | `mine-roles` | Weekly | A role-mining snapshot for access reviews. | | `config-plan --fail-on-drift`, `check-invariants --fail-on-broken` | Every deploy, and nightly | CI gates against drift and broken guardrails. | | `doctor --strict` | After each deploy, and as a health check | Fails on any error or warning finding. | ```sh title="crontab" CONFIG=/etc/better-iam/better-iam.config.mjs # Every minute: deliver email, SMS, and webhooks. * * * * * better-iam outbox --config $CONFIG # Every 5 minutes: continuous audit archiving. */5 * * * * better-iam audit-archive --config $CONFIG # Hourly: expire, sweep, then apply package rules; reconcile again every quarter hour. 0 * * * * better-iam purge --config $CONFIG && better-iam sweep --config $CONFIG && better-iam reconcile --config $CONFIG --fail-on-attention 15,30,45 * * * * better-iam reconcile --config $CONFIG --fail-on-attention # Hourly: guardrails and due certification campaigns. 30 * * * * better-iam monitor-invariants --config $CONFIG && better-iam close-certifications --config $CONFIG # Daily: owner digest and personal reminders, then deliver them. 0 7 * * * better-iam digest --config $CONFIG && better-iam remind --config $CONFIG && better-iam outbox --config $CONFIG ``` If your application registers subscribers with `iam.events.subscribe`, run the outbox step in that process instead (`iam.auth.dispatchOutbox()` and `iam.events.dispatch()` every minute): the `outbox` command also dispatches queued audit hooks, and the events it dispatches never reach subscribers that live in another process. ## Common flags [#common-flags] Commands that load the configuration take `--config`; commands that act as a member also take `--url` and `--profile`; every command that prints JSON takes `--format` and `--query`. Every flag also accepts the `--flag=value` form, and `better-iam help ` prints the flags of the installed version. | Flag | Value | Description | | ----------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--config` | `PATH` | Where to write the configuration | | `--format` | `FORMAT` | Output as json (indented, default), compact (one line), or table | | `--query` | `PATH` | Print only part of the result, e.g. summary.create or findings\[].kind (strings print raw) | | `--url` | `URL` | Call a running IAM server ([https://host](https://host) or [https://host/api/iam](https://host/api/iam)) instead of loading the configuration (env `BETTER_IAM_URL`) | | `--profile` | `NAME` | Saved session to act as (see login); ignored while BETTER\_IAM\_TOKEN is set (env `BETTER_IAM_PROFILE`) | ## Environment variables [#environment-variables] | Variable | Meaning | | -------------------------- | ------------------------------------------------------------------------------------------------------------ | | `BETTER_IAM_CONFIG` | Configuration module, like --config | | `BETTER_IAM_TOKEN` | Session or API key token commands act as | | `BETTER_IAM_URL` | IAM server token commands call, like --url | | `BETTER_IAM_PROFILE` | Saved session to use, like --profile | | `BETTER_IAM_TENANT` | Default --tenant for token commands | | `BETTER_IAM_CREDENTIALS` | File saved sessions live in (default \~/.config/better-iam/credentials.json, %APPDATA% on Windows) | | `BETTER_IAM_ROOT_EMAIL` | bootstrap / recover-root: the root administrator (with BETTER\_IAM\_ROOT\_NAME, BETTER\_IAM\_ROOT\_PASSWORD) | | `BETTER_IAM_ROOT_NAME` | bootstrap / recover-root: display name | | `BETTER_IAM_ROOT_PASSWORD` | bootstrap / recover-root: password (never a flag) | | `BETTER_IAM_PASSWORD` | login: the password, instead of the prompt | | `BETTER_IAM_MFA_CODE` | login: the authenticator code, instead of the prompt | | `BETTER_IAM_DATABASE_URL` | Without a configuration file: the database (postgres\://, sqlite:PATH, libsql://), with BETTER\_IAM\_SECRET | | `BETTER_IAM_SECRET` | Without a configuration file: the deployment secret | ## Commands [#commands] | Command | What it does | | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | [`init`](#init) | Writes a starter `better-iam.config.mjs` for SQLite, PostgreSQL, or libSQL. | | [`migrate`](#migrate) | Creates or upgrades the database schema and applies plugin migrations and one-time data upgrades. | | [`bootstrap`](#bootstrap) | Creates the root tenant and the first root administrator of a new installation. | | [`recover-root`](#recover-root) | Creates an additional root administrator when no one can sign in as root any more. | | [`doctor`](#doctor) | Prints the deployment's health: storage details, audit-chain totals, and configuration and job findings. | | [`secret`](#secret) | Prints a new random value for `BETTER_IAM_SECRET`. | | [`outbox`](#outbox) | Delivers pending email, SMS, and webhook messages, then dispatches queued audit hooks. | | [`purge`](#purge) | Runs the retention worker: expires ended access and removes tenants deleted more than N days ago. | | [`sweep`](#sweep) | Deletes expired sessions, devices, relationship tuples, OAuth and SAML artifacts, and old deliveries in short batches. | | [`digest`](#digest) | Emails each organization's owners its access report when there is something to report. | | [`remind`](#remind) | Emails each person whose access ends within N days one reminder listing it. | | [`reconcile`](#reconcile) | Applies access-package rules (birthright access): people who match a rule receive the package, and automatic holders who stopped matching lose it. | | [`close-certifications`](#close-certifications) | Closes every auto-closing certification campaign whose due date has passed and applies its decisions. | | [`monitor-invariants`](#monitor-invariants) | Evaluates the invariants of every organization and records an audit event whenever one breaks or recovers. | | [`rotate-secrets`](#rotate-secrets) | Re-seals authenticator secrets, webhook secrets, and pending deliveries with the current deployment secret. | | [`billing-close`](#billing-close) | Issues billing statements for a month that has ended and deletes raw usage events past their retention. | | [`billing-reminders`](#billing-reminders) | Reminds billing contacts of unpaid invoices before and after their due date. | | [`billing-alerts`](#billing-alerts) | Checks every spend budget and sends alerts for thresholds reached and projections past the budget. | | [`billing-anomalies`](#billing-anomalies) | Checks every billing account for spend spikes and alerts on each once. | | [`billing-seats`](#billing-seats) | Records one seat for every active person of every tenant a seats meter reaches, once per day. | | [`spend`](#spend) | Prints the spend of a tenant and the tenants below it for a month. | | [`audit-verify`](#audit-verify) | Recomputes one tenant's audit hash chain straight from storage and fails when it does not verify. | | [`audit-export`](#audit-export) | Writes one tenant's audit chain to a new JSON Lines file for archiving or outside analysis. | | [`audit-prune`](#audit-prune) | Deletes one tenant's audit events older than N days and appends a checkpoint so the rest of the chain still verifies. | | [`audit-archive`](#audit-archive) | Copies every tenant's new audit events, verified and in chain order, to the configured `auditArchive`. | | [`audit-verify-archive`](#audit-verify-archive) | Verifies one tenant's archived audit chain from the archive files alone, without the database. | | [`store-export`](#store-export) | Writes every record of the database to a JSON Lines snapshot file. | | [`store-import`](#store-import) | Loads a snapshot into an empty database in one transaction. | | [`store-copy`](#store-copy) | Copies the configured database into the empty database of another configuration in one step, for example from SQLite to PostgreSQL. | | [`config-export`](#config-export) | Writes a tenant's access model as a JSON configuration document that `config-plan` and `config-apply` accept. | | [`config-plan`](#config-plan) | Shows the creates, updates, and deletes that applying a configuration file would make, without changing anything. | | [`config-apply`](#config-apply) | Applies a configuration file to a tenant in one transaction. | | [`config-validate`](#config-validate) | Checks a tenant configuration file offline, without a database or a token. | | [`analyze`](#analyze) | Prints a tenant's access-analysis findings as JSON and can fail when serious ones exist. | | [`report`](#report) | Prints a tenant's access report: what ends soon, unused API keys, live elevations, and pending requests. | | [`mine-roles`](#mine-roles) | Prints role-mining suggestions and peer outliers for a tenant as JSON. | | [`check-invariants`](#check-invariants) | Evaluates a tenant's access invariants as a member and can fail the build when one is broken. | | [`whoami`](#whoami) | Prints who the credential in `BETTER_IAM_TOKEN` acts as, and fails when it is no longer valid. | | [`can`](#can) | Checks whether the session or API key may perform an action on a resource. | | [`explain`](#explain) | Shows why another identity would be allowed or denied an action, without signing in as them. | | [`who-can`](#who-can) | Lists every active identity that could perform an action on a resource, with the reason. | | [`api`](#api) | Calls any method of the HTTP API as the session or API key and prints the result. | | [`login`](#login) | Signs in once and saves the session, so later member commands need no `BETTER_IAM_TOKEN`. | | [`logout`](#logout) | Signs a saved session out on the server and forgets it. | | [`profiles`](#profiles) | Lists the sessions saved by `login`, or chooses or removes one. | | [`token`](#token) | Prints the token member commands would act as, for other tools and scripts. | | [`completion`](#completion) | Prints a shell completion script for every command, flag, and fixed flag value. | ### init [#init] Writes a starter `better-iam.config.mjs` for SQLite, PostgreSQL, or libSQL. ```bash better-iam init [--config better-iam.config.mjs] [--database sqlite|postgres|libsql] [--typescript] ``` * **When:** once, when you add Better IAM to a project. * **Needs:** nothing. It touches no database. * **Fails with:** `CONFIG_EXISTS` when the file already exists (it never overwrites one); `INVALID_ARGUMENT` for a `--database` other than `sqlite`, `postgres`, or `libsql`. Flags: * `--config PATH` sets where the file is written (default `better-iam.config.mjs`, or `better-iam.config.ts` with `--typescript`). * `--database sqlite|postgres|libsql` picks the storage adapter (default `sqlite`). * `--typescript` writes a typed `better-iam.config.ts` (loading it needs Node.js 22.18 or later). The generated file is a `defineConfig` factory, so importing it opens no database; the CLI calls it with `{ command, env, cwd }`, and your server uses `betterIam(await configOptions(config))`. It reads its settings from the environment: `BETTER_IAM_SECRET`, `BETTER_IAM_BASE_URL` (default `http://localhost:3000`), `BETTER_IAM_PREVIOUS_SECRETS` (comma-separated, used during a secret rotation), and the database location: `BETTER_IAM_DATABASE` for SQLite (default `./better-iam.db`), `BETTER_IAM_DATABASE_URL` and `BETTER_IAM_DATABASE_TOKEN` for libSQL, or `DATABASE_URL` for PostgreSQL. It starts with sign-up disabled, the catalog permission mode, an example resource type, a `cli` export with flag defaults, and an empty `commands` list for project commands. Edit it, add a `sendEmail` callback, set `BETTER_IAM_SECRET` to a random value of at least 32 characters (`better-iam secret` prints one), then run `migrate`. ```bash better-iam init --database postgres --config better-iam.config.mjs ``` | Flag | Value | Required | Description | | -------------- | -------------------------- | -------- | ------------------------------------------------ | | `--database` | `sqlite\|postgres\|libsql` | no | Database adapter to configure (default `sqlite`) | | `--typescript` | — | no | Write a typed better-iam.config.ts | ### migrate [#migrate] Creates or upgrades the database schema and applies plugin migrations and one-time data upgrades. ```bash better-iam migrate --config better-iam.config.mjs ``` * **When:** on every deploy, before the new version starts serving, and once before `bootstrap`. * **Needs:** the database the configuration points at. No credential. * **Calls:** [`iam.initialize()`](/docs/reference/api#initialize). It is idempotent, and instances migrating at the same time wait for one another instead of failing. Upgrades that add indexes (`0002_query_indexes`, `0004_expiry_indexes`) build them inside the migration transaction, which blocks IAM writes on large tables while it runs, and the first run after upgrading to the chained audit log backfills it in one transaction: run those upgrades in a maintenance window. `store-import` and `store-copy` apply only the core schema, so run `migrate` after them for plugin migrations. It prints `Database and plugin migrations applied.` ```bash better-iam migrate --config /etc/better-iam/better-iam.config.mjs ``` ### bootstrap [#bootstrap] Creates the root tenant and the first root administrator of a new installation. ```bash better-iam bootstrap --config better-iam.config.mjs ``` * **When:** once, after the first `migrate`. * **Needs:** `BETTER_IAM_ROOT_EMAIL` and `BETTER_IAM_ROOT_PASSWORD` (at least 12 characters and accepted by your password policy); `BETTER_IAM_ROOT_NAME` is optional (default `Root administrator`). * **Fails with:** `MISSING_ENV` without the email or password; `ALREADY_INITIALIZED` when a root tenant exists; `WEAK_PASSWORD` or `BREACHED_PASSWORD` for a password the policy refuses. * **Calls:** [`iam.bootstrap()`](/docs/reference/api#bootstrap), audited as `root:bootstrap`. Secrets come from the environment only, never from arguments, so they stay out of shell history and process lists. It prints the new root tenant, the administrator's public identity, and `mfaEnrollmentRequired: true`: root administrators always need MFA, so the first sign-in enrolls a factor before the account can do anything. Keep the tenant ID. `doctor` reports `not-bootstrapped` until this command has run. ```bash export BETTER_IAM_ROOT_EMAIL=platform-admin@example.com export BETTER_IAM_ROOT_PASSWORD="$(cat /run/secrets/better-iam-root-password)" better-iam bootstrap --config better-iam.config.mjs ``` ### recover-root [#recover-root] Creates an additional root administrator when no one can sign in as root any more. ```bash better-iam recover-root --config better-iam.config.mjs ``` * **When:** during an incident, from a trusted machine with the production configuration. * **Needs:** `BETTER_IAM_ROOT_EMAIL` (an address that is not in the root tenant yet), `BETTER_IAM_ROOT_PASSWORD`, and optionally `BETTER_IAM_ROOT_NAME`. * **Fails with:** `MISSING_ENV`; `NOT_INITIALIZED` before `bootstrap` has run; `IDENTITY_EXISTS` when the email already belongs to an identity in the root tenant; `WEAK_PASSWORD` or `BREACHED_PASSWORD`. * **Calls:** [`iam.recoverRoot()`](/docs/reference/api#recoverroot), audited as `root:recover`. It does not reset an existing administrator's password or MFA. It adds a new root administrator with a verified email, which enrolls MFA at its first sign-in. Once you are back in, review the [root administrators](/docs/reference/api/root#listadministrators) and repair or remove the lost account. Every run adds another administrator, so treat it as a break-glass step and alert on `root:recover` events. ```bash export BETTER_IAM_ROOT_EMAIL=break-glass-2026-09@example.com export BETTER_IAM_ROOT_PASSWORD="$(cat /run/secrets/break-glass-password)" better-iam recover-root --config /etc/better-iam/better-iam.config.mjs ``` ### doctor [#doctor] Prints the deployment's health: storage details, audit-chain totals, and configuration and job findings. ```bash better-iam doctor --config better-iam.config.mjs [--strict] [--retention-days N] ``` * **When:** after every deploy (as a gate with `--strict`), and on a schedule to catch jobs that stopped. * **Needs:** the database. No credential; it only reads. * **Fails with:** `DOCTOR_FINDINGS` with `--strict` when any finding is an error or a warning. Without `--strict` it exits 0 whenever it can connect, even to a database without the IAM schema. * **Calls:** [`iam.selfCheck()`](/docs/reference/api#selfcheck). Flags: * `--strict` turns error and warning findings into a failing exit. Informational findings never fail. * `--retention-days N` (0 to 3650, default 30) is the delivery retention your `sweep` runs with, so the sweep backlog is judged the same way. The JSON holds the Node.js version, `rootInitialized` and `rootCount`, `auditChains` and `auditEvents`, `storage` (adapter, schema version, applied migrations with their times, record counts, and durability settings such as SQLite's journal mode or PostgreSQL's `synchronous_commit`), `ok`, and `findings`. Each finding has a `check` id, a `severity`, a `message`, and a `fix`. Errors are a schema behind this release, no root tenant, unsafe SQLite durability, and stored secrets no configured secret opens. Warnings include an in-memory database, asynchronous PostgreSQL commits, a placeholder secret, a short metrics token, no email transport, an unfinished secret rotation, and jobs that are not running (`sweep`, `purge`, `outbox`, audit hooks, and `audit-archive`). ```bash better-iam doctor --config better-iam.config.mjs --strict --retention-days 14 ``` | Flag | Value | Required | Description | | ------------------ | ----- | -------- | --------------------------------------------------------------------- | | `--strict` | — | no | Exit non-zero when any error or warning is found | | `--retention-days` | `N` | no | Delivery retention your sweep uses, to judge its backlog the same way | ### secret [#secret] Prints a new random value for `BETTER_IAM_SECRET`. ```bash better-iam secret [--bytes N] [--env] ``` * **When:** once per deployment, and again when you rotate the secret (put the old value in `BETTER_IAM_PREVIOUS_SECRETS` first; see [secrets](/docs/operations/deployment/secrets)). * **Needs:** nothing. It reads no configuration and stores nothing. * **Fails with:** `INVALID_ARGUMENT` for `--bytes` outside 24 to 256. Flags: * `--bytes N` sets how many random bytes the secret encodes (default 48, which prints 64 URL-safe characters). * `--env` prints a `BETTER_IAM_SECRET=value` line for a `.env` file. The value comes from the operating system's random generator. Treat the output like a password: send it to your secret store, not to a log. ```bash better-iam secret --env >> .env ``` | Flag | Value | Required | Description | | --------- | ----- | -------- | ---------------------------------------------------------------- | | `--bytes` | `N` | no | Random bytes (the secret is their base64url form) (default `48`) | | `--env` | — | no | Print BETTER\_IAM\_SECRET=value | ### outbox [#outbox] Delivers pending email, SMS, and webhook messages, then dispatches queued audit hooks. ```bash better-iam outbox --config better-iam.config.mjs ``` * **When:** every minute. * **Needs:** the delivery callbacks in the configuration (`sendEmail`, `sendSms`). No credential. * **Calls:** `iam.auth.dispatchOutbox()`, then [`iam.dispatchAuditHooks()`](/docs/reference/api#dispatchaudithooks). Messages are written to the outbox in the same transaction as the change that caused them and wait there until this runs. It prints `{ delivered, failed, abandoned }`. A failed attempt is retried with exponential backoff from 30 seconds to one hour and abandoned after `authentication.maxDeliveryAttempts` (default 25). Delivery is at least once, so transports should deduplicate by message ID. The audit-hook step reaches plugin `afterAudit` hooks and `events.onEvent` from the configuration file only. Events it dispatches are marked delivered and never reach subscribers your application registered with `iam.events.subscribe`, so if you use subscribers, run `iam.auth.dispatchOutbox()` and `iam.events.dispatch()` in that process instead of this command. ```bash better-iam outbox --config /etc/better-iam/better-iam.config.mjs ``` ### purge [#purge] Runs the retention worker: expires ended access and removes tenants deleted more than N days ago. ```bash better-iam purge --config better-iam.config.mjs [--retention-days N] ``` * **When:** hourly, at least daily. * **Needs:** no credential. Recorded as `deployment-operator`. * **Calls:** [`iam.purgeDeleted()`](/docs/reference/api#purgedeleted). Flags: * `--retention-days N` (0 to 3650, default 30) is how long a deleted tenant is kept before its records are removed. `0` removes deleted tenants on the next run, which cannot be undone. It disables identities past their scheduled end (`identity:expire`, sessions revoked), deletes expired temporary bindings, lapsed memberships, ended activations, and package assignments past their end, marks stale access and package requests expired, removes expired challenges, rate-limit counters, and network blocks, and deletes every record of tenants tombstoned before the cutoff (one `tenants:purge` event per tenant tree). Audit records always remain. It prints `purgedTenants`, `deletedRecords`, `expiredBindings`, `expiredRequests`, `expiredIdentities`, `expiredActivations`, `expiredMemberships`, and `expiredAssignments`, and it is idempotent. ```bash better-iam purge --config better-iam.config.mjs --retention-days 30 ``` | Flag | Value | Required | Description | | ------------------ | ----- | -------- | -------------------------------------------------- | | `--retention-days` | `N` | no | Keep deleted tenants this many days (default `30`) | ### sweep [#sweep] Deletes expired sessions, devices, relationship tuples, OAuth and SAML artifacts, and old deliveries in short batches. ```bash better-iam sweep --config better-iam.config.mjs [--limit N] [--retention-days N] ``` * **When:** hourly or daily, beside `purge`. * **Needs:** no credential. Deletions are not audited. * **Calls:** [`iam.sweepExpired()`](/docs/reference/api#sweepexpired). Flags: * `--limit N` (1 to 1,000,000, default 10,000) is the most records one run deletes. * `--retention-days N` (0 to 3650, default 30) is how long delivered and abandoned outbox messages and failed Shared Signals deliveries are kept. Pass the same value to `doctor --retention-days`. It keeps storage, and scans such as outbox delivery, from growing with traffic. Batches of 500 are deleted in their own short transactions, so it can run during traffic. API keys, pending deliveries, invitations, access requests, usage records, and SCIM connections are never deleted by age. It prints `{ deleted, total, truncated }`; when `truncated` is true more records are due, so run it again or raise `--limit`. ```bash better-iam sweep --config better-iam.config.mjs --limit 50000 --retention-days 14 ``` | Flag | Value | Required | Description | | ------------------ | ----- | -------- | ------------------------------------------------------------------ | | `--limit` | `N` | no | Most records deleted per run (default `10000`) | | `--retention-days` | `N` | no | Keep delivered and failed deliveries this many days (default `30`) | ### digest [#digest] Emails each organization's owners its access report when there is something to report. ```bash better-iam digest --config better-iam.config.mjs [--tenant TENANT_ID] [--within-days N] [--unused-days N] ``` * **When:** daily, followed by `outbox`. * **Needs:** `authentication.sendEmail` in the configuration. No credential; recorded as `tenant:access-digest`. * **Fails with:** `DELIVERY_REQUIRED` without an email transport. * **Calls:** [`iam.sendAccessDigest()`](/docs/reference/api#sendaccessdigest). Flags: * `--tenant ID` limits the run to one organization (default: every active organization). * `--within-days N` and `--unused-days N` (each 0 to 3650, default 30) set the report windows, as in `report`. The owners of each organization with findings receive one `access-digest` email each, with the counts and the full report as JSON. Organizations with nothing to report, with no owner who has an email address, or already digested in the last 20 hours are skipped, so a rerun never emails twice. It prints `{ sent, skipped }`. ```bash better-iam digest --config better-iam.config.mjs --within-days 14 && better-iam outbox --config better-iam.config.mjs ``` | Flag | Value | Required | Description | | --------------- | ----------- | -------- | --------------------------------------------------------- | | `--tenant` | `TENANT_ID` | no | Only this organization (default: every active one) | | `--within-days` | `N` | no | Report access ending within this many days (default `30`) | | `--unused-days` | `N` | no | Report keys unused for this many days (default `30`) | ### remind [#remind] Emails each person whose access ends within N days one reminder listing it. ```bash better-iam remind --config better-iam.config.mjs [--tenant TENANT_ID] [--within-days N] ``` * **When:** daily, beside `digest`, followed by `outbox`. * **Needs:** `authentication.sendEmail` in the configuration. No credential; recorded as `identity:expiry-reminder`. * **Fails with:** `DELIVERY_REQUIRED` without an email transport; `INVALID_ARGUMENT` for `--unused-days`, which `remind` does not take. * **Calls:** [`iam.sendExpiryReminders()`](/docs/reference/api#sendexpiryreminders). Flags: * `--tenant ID` limits the run to one organization. * `--within-days N` (1 to 365, default 7) is how far ahead to look. It covers the person's own account end date, direct role bindings, temporary group memberships, and access-package assignments. Each item is reminded once per end date, so reruns are harmless and extended access is reminded again when its new end comes near. It prints `{ sent, skipped }`. ```bash better-iam remind --config better-iam.config.mjs --within-days 3 ``` | Flag | Value | Required | Description | | --------------- | ----------- | -------- | -------------------------------------------------------------- | | `--tenant` | `TENANT_ID` | no | Only this organization (default: every active one) | | `--within-days` | `N` | no | Remind about access ending within this many days (default `7`) | ### reconcile [#reconcile] Applies access-package rules (birthright access): people who match a rule receive the package, and automatic holders who stopped matching lose it. ```bash better-iam reconcile --config better-iam.config.mjs [--tenant TENANT_ID [--package PACKAGE_ID [--confirm]]] [--limit N] [--fail-on-attention] ``` * **When:** every 15 minutes, after `purge`. * **Needs:** no credential and no email transport. Each change runs under the rule owner's grant authority. * **Fails with:** `RECONCILE_ATTENTION` with `--fail-on-attention` when a change failed, changes were held back, a rule is suspended, or an organization could not be processed; `INVALID_ARGUMENT` for `--package` without `--tenant` or `--confirm` without `--package`. * **Calls:** [`iam.reconcilePackages()`](/docs/reference/api#reconcilepackages). Flags: * `--tenant ID` limits the run to one organization, and `--package ID` (with `--tenant`) to one package. * `--confirm` (with `--package`) approves the changes the brake held back for that package and applies them in this run. * `--limit N` (1 to 10000, default 1000) caps the changes per organization per run. * `--fail-on-attention` exits non-zero when anything needs a person. SCIM provisioning, invitations, and attribute and group changes take effect in rule-based packages only through this command. It prints `assigned`, `refreshed`, `restored`, `ending`, `revoked`, `stale`, `failed`, `suspended`, `braked`, and `truncated`; `truncated: true` means run it again. Scheduled runs hold back unusually large changes (`braked`) until someone confirms them, so a rule edit or a directory glitch cannot revoke everyone at once. See [automatic assignment](/docs/guides/privileged-access/automatic-assignment). ```bash better-iam reconcile --config better-iam.config.mjs --tenant "$TENANT_ID" --package "$PACKAGE_ID" --confirm ``` | Flag | Value | Required | Description | | --------------------- | ------------ | -------- | ------------------------------------------------------------------------- | | `--tenant` | `TENANT_ID` | no | Only this organization (default: every active one) | | `--package` | `PACKAGE_ID` | no | Only this package (needs --tenant) | | `--confirm` | — | no | Apply a held-back large change (needs --package) | | `--limit` | `N` | no | Most changes per organization and run (default `1000`) | | `--fail-on-attention` | — | no | Exit non-zero when a change failed, was held back, or a rule is suspended | ### close-certifications [#close-certifications] Closes every auto-closing certification campaign whose due date has passed and applies its decisions. ```bash better-iam close-certifications --config better-iam.config.mjs [--tenant TENANT_ID] ``` * **When:** hourly or daily, with the other jobs. * **Needs:** no credential. Revocations run under each campaign creator's grant authority; recorded as `certification:auto-close`. * **Calls:** [`iam.closeOverdueCertifications()`](/docs/reference/api#closeoverduecertifications). Flags: * `--tenant ID` limits the run to one organization. Only campaigns created with `autoClose` are touched. Each one closes in its own transaction: revoked bindings are removed (each audited as `iam:bindings:delete`), and items nobody decided follow the campaign's `undecided` setting. It prints `{ closed, skipped }`, where `skipped` counts campaigns that are not due yet. See [certifications](/docs/guides/governance/certifications). ```bash better-iam close-certifications --config better-iam.config.mjs ``` | Flag | Value | Required | Description | | ---------- | ----------- | -------- | -------------------------------------------------- | | `--tenant` | `TENANT_ID` | no | Only this organization (default: every active one) | ### monitor-invariants [#monitor-invariants] Evaluates the invariants of every organization and records an audit event whenever one breaks or recovers. ```bash better-iam monitor-invariants --config better-iam.config.mjs [--tenant TENANT_ID] ``` * **When:** hourly, and after configuration changes. * **Needs:** no credential. Recorded as `deployment-operator`. * **Calls:** [`iam.checkInvariants()`](/docs/reference/api#checkinvariants). Flags: * `--tenant ID` limits the run to one organization. `invariant:broken` is recorded when an invariant starts failing or gains violators, and `invariant:restored` when it passes again, once per change, so a webhook subscribed to `invariant:*` alerts without repeating itself. It prints `{ checked, broken, restored }` and exits 0 whenever it runs: alert from the webhook, or use `check-invariants --fail-on-broken` when you need a failing exit. ```bash better-iam monitor-invariants --config better-iam.config.mjs ``` | Flag | Value | Required | Description | | ---------- | ----------- | -------- | -------------------------------------------------- | | `--tenant` | `TENANT_ID` | no | Only this organization (default: every active one) | ### rotate-secrets [#rotate-secrets] Re-seals authenticator secrets, webhook secrets, and pending deliveries with the current deployment secret. ```bash better-iam rotate-secrets --config better-iam.config.mjs [--dry-run] ``` * **When:** during a secret rotation, after every process runs with the new `secret` and the old value in `previousSecrets` (configurations from `init` read it from `BETTER_IAM_PREVIOUS_SECRETS`, comma-separated). * **Needs:** no credential. * **Fails with:** `UNREADABLE_SECRETS`, after printing the result, when stored values open with no configured secret, which means the secret that sealed them is missing from `previousSecrets`. * **Calls:** [`iam.rotateSecrets()`](/docs/reference/api#rotatesecrets). Flags: * `--dry-run` only counts what would be re-sealed and changes nothing. It prints `{ resealed, unreadable, current, complete, done }` and writes in short transactions, so you can stop it and run it again. Repeat until `done` is true, wait a day for emailed links and assertions issued under the old secret to expire, then remove `previousSecrets` everywhere. Sessions and API keys do not depend on the secret, so nobody is signed out. See [rotating the deployment secret](/docs/operations/deployment/secrets). ```bash better-iam rotate-secrets --config better-iam.config.mjs --dry-run better-iam rotate-secrets --config better-iam.config.mjs ``` | Flag | Value | Required | Description | | ----------- | ----- | -------- | ---------------------------------- | | `--dry-run` | — | no | Only count what would be re-sealed | ### billing-close [#billing-close] Issues billing statements for a month that has ended and deletes raw usage events past their retention. ```bash better-iam billing-close --config better-iam.config.mjs [--period YYYY-MM] [--tenant TENANT_ID] [--draft] ``` * **When:** daily; accounts already invoiced for the month are skipped, so repeated runs are safe. * **Needs:** no credential. Each statement is recorded as `billing:statement`; with `authentication.sendEmail` it is emailed to the account's billing emails (or owners) as `billing-statement`. * **Fails with:** `INVALID_INPUT` for the current or a future month. * **Calls:** `iam.billing.closePeriod()`. Flags: * `--period YYYY-MM` is the month to close (default: last month). * `--tenant ID` closes only the billing account that pays for that tenant. * `--draft` keeps the invoices as drafts, refreshed on every run, until finalized (also the default with the option `billing.autoFinalize: false`). Each account with something to bill (usage of meters defined above it, subscription fees and seats, pending invoice items) gets one invoice with its lines, coupons, credit applied earliest expiry first, and a breakdown of usage by project, team, department and person. See [billing](/docs/reference/api/billing). | Flag | Value | Required | Description | | ---------- | ----------- | -------- | -------------------------------------------------- | | `--period` | `YYYY-MM` | no | The billing month | | `--tenant` | `TENANT_ID` | no | Only the billing account that pays for this tenant | | `--draft` | — | no | Keep the invoices as drafts to review and finalize | ### billing-reminders [#billing-reminders] Reminds billing contacts of unpaid invoices before and after their due date. ```bash better-iam billing-reminders --config better-iam.config.mjs ``` * **When:** daily. * **Needs:** no credential. Each reminder is recorded as `billing:payment-reminder`; with `authentication.sendEmail` it is emailed to the invoice's billing emails as `payment-reminder`. * **Calls:** `iam.billing.sendPaymentReminders()`. Reminders go out at each step of the `billing.paymentReminderDays` option (default 3 days before the due date, on it, and 7 and 14 days after), once per step; a step missed while the job did not run is skipped for the latest one reached. Paid, void and uncollectible invoices get none. ### billing-alerts [#billing-alerts] Checks every spend budget and sends alerts for thresholds reached and projections past the budget. ```bash better-iam billing-alerts --config better-iam.config.mjs [--tenant TENANT_ID] ``` * **When:** hourly. * **Needs:** no credential. Alerts are recorded as `billing:budget-alert` (forward them with a webhook) and emailed as `spend-alert` when mail is configured. * **Calls:** `iam.billing.checkBudgets()`. Flags: * `--tenant ID` checks only the budgets that tenant owns. Each threshold alerts once per budget window (a month, quarter or year), and the forecast alert once when the linear projection passes 100%. | Flag | Value | Required | Description | | ---------- | ----------- | -------- | ----------------------------------------------------- | | `--tenant` | `TENANT_ID` | no | Only budgets this tenant owns (default: every budget) | ### billing-anomalies [#billing-anomalies] Checks every billing account for spend spikes and alerts on each once. ```bash better-iam billing-anomalies --config better-iam.config.mjs [--tenant TENANT_ID] [--day YYYY-MM-DD] [--factor N] [--minimum N] ``` * **When:** daily, shortly after midnight in the billing time zone. * **Needs:** no credential. Each spike is recorded as `billing:anomaly`; with `authentication.sendEmail` each account's billing emails (or owners) get one `spend-anomaly` email listing the largest five. * **Fails with:** `INVALID_INPUT` for a malformed `--day`. * **Calls:** `iam.billing.detectAnomalies()`. Flags: * `--day YYYY-MM-DD` is the day to check (default: yesterday). * `--factor N` (2 to 1000, default 3) is how many times the usual daily spend counts as a spike. * `--minimum N` (default 10) is the smallest spend and increase worth reporting, in currency units. * `--tenant ID` checks only the billing account that pays for that tenant. A person, team or meter counts as spiking when its spend on the day is at least the factor times its average over the 14 days before and at least the minimum more; new spending counts when it reaches the minimum. | Flag | Value | Required | Description | | ----------- | ------------ | -------- | ----------------------------------------------------------------------------- | | `--tenant` | `TENANT_ID` | no | Only the billing account that pays for this tenant | | `--day` | `YYYY-MM-DD` | no | The day to check | | `--factor` | `N` | no | Times the usual daily spend that counts as a spike (default `3`) | | `--minimum` | `N` | no | Smallest spend and increase worth reporting, in currency units (default `10`) | ### billing-seats [#billing-seats] Records one seat for every active person of every tenant a seats meter reaches, once per day. ```bash better-iam billing-seats --config better-iam.config.mjs [--meter KEY] [--tenant TENANT_ID] [--include-service-accounts] ``` * **When:** daily. * **Needs:** no credential, and a meter with the key (`seats` by default) defined by the platform or an organization. * **Calls:** `iam.billing.recordSeats()`. Flags: * `--meter KEY` is the meter to record on (default `seats`). * `--tenant ID` limits the run to that tenant and the tenants below it. * `--include-service-accounts` counts service accounts and agents as seats too. A `sum` meter then counts seat-days and a `unique` meter active seats per month; each seat is attributed to its person, their teams and department. Tenants the meter does not reach are counted under `skippedTenants`. | Flag | Value | Required | Description | | ---------------------------- | ----------- | -------- | ------------------------------------------------- | | `--meter` | `KEY` | no | The meter seats are recorded on (default `seats`) | | `--tenant` | `TENANT_ID` | no | Only this tenant and the tenants below it | | `--include-service-accounts` | — | no | Count service accounts and agents as seats too | ### spend [#spend] Prints the spend of a tenant and the tenants below it for a month. ```bash better-iam spend --config better-iam.config.mjs --tenant TENANT_ID [--period YYYY-MM] [--group-by meter|identity|agent|team|department|tenant|day] [--team TEAM_ID] [--department DEPARTMENT_ID] [--identity IDENTITY_ID] [--meter KEY] [--url URL] [--profile NAME] ``` * **When:** for finance exports and scripts, or to check a team's spend from a terminal. * **Needs:** `BETTER_IAM_TOKEN` with `iam:billing:read` in the tenant. * **Fails with:** `INVALID_ARGUMENT` for an unknown `--group-by`; `NOT_FOUND` for a team, department or identity outside the tenant. Flags: * `--tenant ID` (or `BETTER_IAM_TENANT`) is the tenant to report on. * `--period YYYY-MM` is the month (default: the current one, with a forecast). * `--group-by meter|identity|agent|team|department|tenant|day` (default `meter`). * `--team ID`, `--department ID`, `--identity ID` and `--meter KEY` narrow the report. ```bash better-iam spend --tenant "$ACME" --group-by team --format table ``` | Flag | Value | Required | Description | | -------------- | ------------------------------------------------------- | -------- | ---------------------------------------------- | | `--tenant` | `TENANT_ID` | yes | The tenant to act in (env `BETTER_IAM_TENANT`) | | `--period` | `YYYY-MM` | no | The billing month | | `--group-by` | `meter\|identity\|agent\|team\|department\|tenant\|day` | no | How rows are grouped (default `meter`) | | `--team` | `TEAM_ID` | no | Only this team (with its sub-teams) | | `--department` | `DEPARTMENT_ID` | no | Only this department (with those below it) | | `--identity` | `IDENTITY_ID` | no | Only this person or account | | `--meter` | `KEY` | no | Only this meter | ### audit-verify [#audit-verify] Recomputes one tenant's audit hash chain straight from storage and fails when it does not verify. ```bash better-iam audit-verify --config better-iam.config.mjs --tenant TENANT_ID ``` * **When:** during an incident or an audit, after restoring a backup, or on a schedule as a tamper check. * **Needs:** `--tenant`. No credential, and it records nothing. * **Fails with:** `AUDIT_CHAIN_BROKEN` when an event was edited or removed, or the chain no longer ends at its recorded head. It reads every chained event of the tenant, recomputes each hash and link, compares the end with the chain head, and prints `{ tenantId, valid, head }` with the position of the first failure. Because it needs no credential and writes nothing, it is safe to run against production at any time; it holds the whole chain in memory. The API equivalent, authorized and audited, is [`audit.verify`](/docs/reference/api/audit#verify). See [audit chain](/docs/guides/events/audit-chain). ```bash better-iam audit-verify --config better-iam.config.mjs --tenant "$TENANT_ID" ``` | Flag | Value | Required | Description | | ---------- | ----------- | -------- | ------------------------------------ | | `--tenant` | `TENANT_ID` | yes | The tenant whose audit chain to read | ### audit-export [#audit-export] Writes one tenant's audit chain to a new JSON Lines file for archiving or outside analysis. ```bash better-iam audit-export --config better-iam.config.mjs --tenant TENANT_ID --output PATH ``` * **When:** on demand (a legal hold or an auditor's request), or before `audit-prune` when you do not use `audit-archive`. * **Needs:** `--tenant` and `--output`. No credential, and it records nothing. * **Fails with:** a generic failure when the output file already exists; it never overwrites one. Each line is one chained event in sequence order, exactly as stored with its hashes, so the file can be verified on its own later with `verifyAuditChain`. It prints `{ tenantId, output, count, firstSequence, lastSequence, head }`; keep the head with the file as the point the chain must end at. For continuous, verified copies use `audit-archive`. ```bash better-iam audit-export --config better-iam.config.mjs --tenant "$TENANT_ID" --output "audit-$TENANT_ID-$(date +%F).jsonl" ``` | Flag | Value | Required | Description | | ---------- | ----------- | -------- | --------------------------------------------- | | `--tenant` | `TENANT_ID` | yes | The tenant whose audit chain to read | | `--output` | `PATH` | yes | The .jsonl file to create (it must not exist) | ### audit-prune [#audit-prune] Deletes one tenant's audit events older than N days and appends a checkpoint so the rest of the chain still verifies. ```bash better-iam audit-prune --config better-iam.config.mjs --tenant TENANT_ID [--retention-days N] ``` * **When:** on a schedule that matches your retention policy, after the events were archived or exported. * **Needs:** `--tenant`. No credential; recorded as `audit:prune` by `deployment-operator`. * **Calls:** [`iam.pruneAudit()`](/docs/reference/api#pruneaudit). Flags: * `--retention-days N` (0 to 36500, default 365) is how old an event must be before it is deleted. It deletes the oldest events up to the first one newer than the cutoff, in one transaction, and records the sequence and hash the chain now starts after. With `auditArchive` configured, or once the tenant has an archive cursor, it never deletes an event the archive does not hold yet, and prints `heldForArchive: true` when it stopped early. It prints `{ deleted, prunedThroughSequence, prunedThroughHash }`; a rerun with the same retention deletes nothing new. Deleted events are gone from the database for good, so archive first. ```bash better-iam audit-prune --config better-iam.config.mjs --tenant "$TENANT_ID" --retention-days 400 ``` | Flag | Value | Required | Description | | ------------------ | ----------- | -------- | ----------------------------------------------------- | | `--tenant` | `TENANT_ID` | yes | The tenant whose audit chain to read | | `--retention-days` | `N` | no | Keep events newer than this many days (default `365`) | ### audit-archive [#audit-archive] Copies every tenant's new audit events, verified and in chain order, to the configured `auditArchive`. ```bash better-iam audit-archive --config better-iam.config.mjs [--tenant TENANT_ID] [--limit N] ``` * **When:** every few minutes, at least hourly. * **Needs:** `auditArchive` in the configuration, for example `createJsonlAuditArchive`. No credential. * **Fails with:** `AUDIT_ARCHIVE_FAILED`, after printing the result, when a tenant's chain did not verify or the sink failed or refused a conflicting batch; `NO_AUDIT_ARCHIVE` when no archive is configured. * **Calls:** [`iam.archiveAudit()`](/docs/reference/api#archiveaudit). Flags: * `--tenant ID` archives one tenant only. * `--limit N` (a positive integer, default 100,000, at most 10,000,000) caps the events archived per run. It prints `archived` per tenant, `batches`, `failed` (with `AUDIT_CHAIN_BROKEN`, `ARCHIVE_WRITE_FAILED`, or `ARCHIVE_CONFLICT` per tenant), `gaps`, `busy`, and `truncated`. Overlapping runs are safe: a tenant another run is archiving is skipped and listed under `busy`. Once archiving runs, `audit-prune` only deletes events the archive holds. Check the archive on its own with `audit-verify-archive`. See [continuous audit archiving](/docs/operations/jobs#continuous-audit-archiving). ```bash better-iam audit-archive --config /etc/better-iam/better-iam.config.mjs ``` | Flag | Value | Required | Description | | ---------- | ----------- | -------- | --------------------------------------- | | `--tenant` | `TENANT_ID` | no | Only this tenant (default: all) | | `--limit` | `N` | no | Most events archived per tenant and run | ### audit-verify-archive [#audit-verify-archive] Verifies one tenant's archived audit chain from the archive files alone, without the database. ```bash better-iam audit-verify-archive --directory /var/lib/better-iam/audit --tenant TENANT_ID ``` * **When:** periodically, and before relying on the archive (after an incident, or before discarding database backups). * **Needs:** `--directory` (the directory given to `createJsonlAuditArchive`) and `--tenant`. No configuration, database, or credential. * **Fails with:** `AUDIT_ARCHIVE_INVALID` when a sequence is missing, a hash or link does not recompute, or two overlapping files disagree about the same sequence. Files can overlap after a crash, and each sequence must then carry the identical event. It prints the tenant, the number of files, the number of conflicts, and the verification result. Because it reads only the files, run it where the archive lives, for example on the backup host. ```bash better-iam audit-verify-archive --directory /var/lib/better-iam/audit --tenant "$TENANT_ID" ``` | Flag | Value | Required | Description | | ------------- | ----------- | -------- | ----------------------------------------------- | | `--directory` | `PATH` | yes | The archive directory (createJsonlAuditArchive) | | `--tenant` | `TENANT_ID` | yes | The tenant whose archive to verify | ### store-export [#store-export] Writes every record of the database to a JSON Lines snapshot file. ```bash better-iam store-export --config better-iam.config.mjs --output PATH ``` * **When:** before moving a deployment to another database or adapter, or as a logical backup. * **Needs:** `--output`. No credential. * **Fails with:** a generic failure when the output file already exists. On any failure the partial file is removed. The file holds a header, one line per record, and a trailer with counts. It is read in one transaction, so the snapshot is consistent, but that transaction holds the write lock until the export finishes, so IAM writes wait meanwhile. The file is created readable by its owner only and holds password hashes, sessions, and encrypted secrets: protect it like the database. It prints `{ output, records, collections }`. Load it with `store-import`. ```bash better-iam store-export --config better-iam.config.mjs --output /secure/backups/better-iam-snapshot.jsonl ``` | Flag | Value | Required | Description | | ---------- | ------ | -------- | ---------------------------------------------------------- | | `--output` | `PATH` | yes | The snapshot file to create (mode 0600; it must not exist) | ### store-import [#store-import] Loads a snapshot into an empty database in one transaction. ```bash better-iam store-import --config better-iam.config.mjs --input PATH ``` * **When:** when moving a deployment to a new database, after `store-export`. * **Needs:** `--input`, and a configuration whose database holds no IAM records. No credential. * **Fails with:** `STORE_NOT_EMPTY` when the target already has records; `SNAPSHOT_TRUNCATED` for a file without its trailer or with counts that disagree with it; `SNAPSHOT_INVALID` for a corrupt line. It applies the core schema to the target first (not plugin migrations), then inserts every record verbatim in one transaction, so any failure leaves the database without records. Records keep their IDs, password hashes, encrypted secrets, and audit chains, so the target configuration must use the same `secret`. Afterwards run `migrate` with the same configuration for plugin migrations, and on PostgreSQL run `VACUUM ANALYZE iam_records` so lookups use the index immediately. See [storage](/docs/operations/storage). ```bash better-iam store-import --config postgres.config.mjs --input /secure/backups/better-iam-snapshot.jsonl ``` | Flag | Value | Required | Description | | --------- | ------ | -------- | -------------------- | | `--input` | `PATH` | yes | The snapshot to load | ### store-copy [#store-copy] Copies the configured database into the empty database of another configuration in one step, for example from SQLite to PostgreSQL. ```bash better-iam store-copy --config better-iam.config.mjs --target-config PATH ``` * **When:** when moving a deployment between databases or adapters without an intermediate file. * **Needs:** `--target-config`, naming a different configuration file whose database is empty. No credential. * **Fails with:** `INVALID_ARGUMENT` when `--target-config` is the same file as `--config`; `SAME_DATABASE` when both point at the same database; `STORE_NOT_EMPTY` when the target already has records. It migrates the target's core schema, reads the source in one transaction, and writes the target in one transaction, so the copy is consistent and all-or-nothing. Use the same `secret` in both configurations. Then run `migrate --config` with the target configuration, point the application at it, and on PostgreSQL run `VACUUM ANALYZE iam_records`. ```bash better-iam store-copy --config sqlite.config.mjs --target-config postgres.config.mjs ``` | Flag | Value | Required | Description | | ----------------- | ------ | -------- | ------------------------------------------ | | `--target-config` | `PATH` | yes | Configuration of the empty target database | ### config-export [#config-export] Writes a tenant's access model as a JSON configuration document that `config-plan` and `config-apply` accept. ```bash better-iam config-export --config better-iam.config.mjs --tenant TENANT_ID [--output PATH] [--url URL] [--profile NAME] ``` * **When:** once to bring an existing tenant under version control, then whenever you want a snapshot. * **Needs:** `--tenant` and `BETTER_IAM_TOKEN`, a session or API key with `iam:config:read`. * **Fails with:** `MISSING_ENV` without a token; `ACCESS_DENIED` without the permission. * **Calls:** [`config.export`](/docs/reference/api/config#export). Flags: * `--output PATH` writes a new file (never overwriting one) and prints `{ tenantId, output }`. Without it, the document goes to standard output. The document refers to everything by name instead of by ID: tenant-defined resource types, policies, roles, groups with their members' emails, group bindings, access packages with their automatic-assignment rules, the tenant access policy, and, when the tenant has any, invariants and agreements. Commit it, review changes as pull requests, and apply them with `config-apply`. See [configuration as code](/docs/guides/privileged-access/config-as-code). ```bash BETTER_IAM_TOKEN="$CONFIG_READER_KEY" better-iam config-export --config better-iam.config.mjs --tenant "$TENANT_ID" --output tenant.json ``` | Flag | Value | Required | Description | | ---------- | ----------- | -------- | ------------------------------------------------------------------------------ | | `--tenant` | `TENANT_ID` | yes | The tenant to act in (env `BETTER_IAM_TENANT`) | | `--output` | `PATH` | no | Write to this file instead (.json, or .ts/.mjs for a module); never overwrites | ### config-plan [#config-plan] Shows the creates, updates, and deletes that applying a configuration file would make, without changing anything. ```bash better-iam config-plan --config better-iam.config.mjs --tenant TENANT_ID --input PATH [--prune] [--fail-on-drift] [--url URL] [--profile NAME] ``` * **When:** in CI on every change to the file, and nightly to detect drift made in the console. * **Needs:** `--tenant`, `--input`, and `BETTER_IAM_TOKEN` with `iam:config:read`. * **Fails with:** `CONFIG_DRIFT` with `--fail-on-drift` when anything would change; `INVALID_INPUT` for an invalid document; `MISSING_ENV`; `ACCESS_DENIED`. * **Calls:** [`config.plan`](/docs/reference/api/config#plan). Flags: * `--prune` also plans deletes for the items the file omits, in each kind the file lists. Kinds absent from the file are always left alone. * `--fail-on-drift` exits non-zero, after printing the plan, when it contains any create, update, or delete. The plan lists every change by kind and name with a `summary` of counts. Use the same `--prune` setting here as in `config-apply`, so the plan shows exactly what the apply will do. ```bash BETTER_IAM_TOKEN="$CONFIG_READER_KEY" better-iam config-plan --config better-iam.config.mjs --tenant "$TENANT_ID" --input tenant.json --prune --fail-on-drift ``` | Flag | Value | Required | Description | | ----------------- | ----------- | -------- | ---------------------------------------------------------------------------------- | | `--tenant` | `TENANT_ID` | yes | The tenant to act in (env `BETTER_IAM_TENANT`) | | `--input` | `PATH` | yes | Desired configuration: .json, or a .mjs/.js/.ts module exporting it (or a factory) | | `--prune` | — | no | Also delete items the file omits | | `--fail-on-drift` | — | no | Exit non-zero when anything would change (CI) | ### config-apply [#config-apply] Applies a configuration file to a tenant in one transaction. ```bash better-iam config-apply --config better-iam.config.mjs --tenant TENANT_ID --input PATH [--prune] [--url URL] [--profile NAME] ``` * **When:** from your deployment pipeline after the plan was reviewed, followed by `check-invariants --fail-on-broken`. * **Needs:** `--tenant`, `--input`, and `BETTER_IAM_TOKEN` with `iam:config:apply`, plus the permission and grant authority for every change it makes. * **Fails with:** `ACCESS_DENIED` when any single change is not allowed; `SOD_CONFLICT` or `INVARIANT_VIOLATION` when the result would break a separation-of-duties rule or an enforced invariant; `INVALID_INPUT` for an invalid document; `MISSING_ENV`. * **Calls:** [`config.apply`](/docs/reference/api/config#apply), audited as `config:apply` with the change summary. Flags: * `--prune` deletes items of a listed kind that the file omits. Without it, removing an item from the file does not delete it. Each change is authorized like the equivalent direct API call under the token owner's grant authority, and one failure rolls the whole apply back, so the tenant never ends up half-configured. It prints the plan it applied. Run `config-plan` with the same flags first. ```bash BETTER_IAM_TOKEN="$CONFIG_DEPLOYER_KEY" better-iam config-apply --config better-iam.config.mjs --tenant "$TENANT_ID" --input tenant.json --prune ``` | Flag | Value | Required | Description | | ---------- | ----------- | -------- | ---------------------------------------------------------------------------------- | | `--tenant` | `TENANT_ID` | yes | The tenant to act in (env `BETTER_IAM_TENANT`) | | `--input` | `PATH` | yes | Desired configuration: .json, or a .mjs/.js/.ts module exporting it (or a factory) | | `--prune` | — | no | Also delete items the file omits | ### config-validate [#config-validate] Checks a tenant configuration file offline, without a database or a token. ```bash better-iam config-validate --input PATH [--tenant TENANT_ID] [--strict] ``` * **When:** in a pre-commit hook and as the first CI step, before `config-plan` needs a token and a network. * **Needs:** `--input`. No configuration, database, or credential. * **Fails with:** `INVALID_INPUT` when the document's shape is wrong; `CONFIG_WARNINGS` with `--strict` when it names something it does not define. Flags: * `--input PATH` is the file: JSON, or a `.mjs`, `.js`, or `.ts` module whose default export is the configuration or a factory receiving `{ tenantId, env }`. * `--tenant ID` is passed to such a factory as `tenantId`. * `--strict` fails on warnings. It prints `valid`, a count of items per kind, and `warnings`: each role, binding, package, or invariant that names a policy, role, or group the file does not define. Those names must already exist in the tenant, or `config-plan` fails. ```bash better-iam config-validate --input iam/tenant.config.ts --strict ``` | Flag | Value | Required | Description | | ---------- | ----------- | -------- | ---------------------------------------------------------------------------------- | | `--input` | `PATH` | yes | Desired configuration: .json, or a .mjs/.js/.ts module exporting it (or a factory) | | `--tenant` | `TENANT_ID` | no | Passed to a configuration factory as tenantId | | `--strict` | — | no | Exit non-zero when a reference is not defined in the file | ### analyze [#analyze] Prints a tenant's access-analysis findings as JSON and can fail when serious ones exist. ```bash better-iam analyze --config better-iam.config.mjs --tenant TENANT_ID [--dormant-days N] [--fail-on high|medium|low] [--url URL] [--profile NAME] ``` * **When:** nightly, or as a deployment gate. * **Needs:** `--tenant` and `BETTER_IAM_TOKEN` with `iam:analysis:read`. * **Fails with:** `FINDINGS` with `--fail-on` when an unsuppressed finding at or above that severity exists; `INVALID_INPUT` for a `--dormant-days` outside 1 to 3650; `MISSING_ENV`; `ACCESS_DENIED`. * **Calls:** [`analysis.findings`](/docs/reference/api/analysis#findings). Flags: * `--dormant-days N` (default 90) is how long an account holding access must go unused before it is reported. * `--fail-on high|medium|low` exits non-zero when a finding of that severity or higher exists: `high` fails on high findings only, `low` on any finding. Findings cover dormant access, stale keys, policy lint, and separation-of-duties violations (see the [analysis group](/docs/reference/api/analysis)). Suppressed findings are left out, so suppress accepted risks with [`analysis.suppress`](/docs/reference/api/analysis#suppress) and the gate stays meaningful. ```bash BETTER_IAM_TOKEN="$ANALYST_KEY" better-iam analyze --config better-iam.config.mjs --tenant "$TENANT_ID" --dormant-days 60 --fail-on high ``` | Flag | Value | Required | Description | | ---------------- | ------------------- | -------- | -------------------------------------------------------------- | | `--tenant` | `TENANT_ID` | yes | The tenant to act in (env `BETTER_IAM_TENANT`) | | `--dormant-days` | `N` | no | Days without sign-in after which an identity counts as dormant | | `--fail-on` | `high\|medium\|low` | no | Exit non-zero on a finding of this severity or higher | ### report [#report] Prints a tenant's access report: what ends soon, unused API keys, live elevations, and pending requests. ```bash better-iam report --config better-iam.config.mjs --tenant TENANT_ID [--within-days N] [--unused-days N] [--url URL] [--profile NAME] ``` * **When:** nightly, piped into a ticket or a chat channel. * **Needs:** `--tenant` and `BETTER_IAM_TOKEN` with `iam:identities:read`. The binding and key sections also need `iam:bindings:read` and `iam:credentials:read` and are left out without them. * **Fails with:** `MISSING_ENV`; `ACCESS_DENIED`. * **Calls:** [`reports.access`](/docs/reference/api/reports#access). Flags: * `--within-days N` (0 to 3650, default 30) reports identities and temporary bindings ending within that many days. * `--unused-days N` (0 to 3650, default 30) reports API keys unused for that many days. Unlike `digest`, it acts as a member and emails nobody, so use it when the report should go somewhere other than the owners' inboxes. See [access report](/docs/guides/privileged-access/access-report). ```bash BETTER_IAM_TOKEN="$REPORTER_KEY" better-iam report --config better-iam.config.mjs --tenant "$TENANT_ID" --within-days 14 | jq '.identities.expiring' ``` | Flag | Value | Required | Description | | --------------- | ----------- | -------- | -------------------------------------------------- | | `--tenant` | `TENANT_ID` | yes | The tenant to act in (env `BETTER_IAM_TENANT`) | | `--within-days` | `N` | no | Access ending within this many days (default `30`) | | `--unused-days` | `N` | no | Keys unused for this many days (default `30`) | ### mine-roles [#mine-roles] Prints role-mining suggestions and peer outliers for a tenant as JSON. ```bash better-iam mine-roles --config better-iam.config.mjs --tenant TENANT_ID [--peer-by manager|attribute:NAME] [--url URL] [--profile NAME] ``` * **When:** weekly, as a snapshot for access reviews. * **Needs:** `--tenant` and `BETTER_IAM_TOKEN` with `iam:analysis:read`. * **Fails with:** `INVALID_INPUT` for an identity attribute in `--peer-by` that is not declared; `MISSING_ENV`; `ACCESS_DENIED`. * **Calls:** [`roleMining.suggest`](/docs/reference/api/role-mining#suggest) and [`roleMining.outliers`](/docs/reference/api/role-mining#outliers). Flags: * `--peer-by manager|attribute:NAME` groups people for outlier detection by shared manager (the default) or by a declared identity attribute, such as `attribute:department`. Suggestions are role bundles to grant as access packages, roles every member of a group holds directly (bind them to the group instead), direct bindings a group already covers, and duplicate roles. Outliers are roles few peers hold (access that outlived a move) and roles most peers hold that a person lacks. The command only reads; apply a suggestion with [`roleMining.apply`](/docs/reference/api/role-mining#apply) or in the console. See [usage and role mining](/docs/guides/governance/usage-and-mining). ```bash BETTER_IAM_TOKEN="$ANALYST_KEY" better-iam mine-roles --config better-iam.config.mjs --tenant "$TENANT_ID" --peer-by attribute:department ``` | Flag | Value | Required | Description | | ----------- | ------------------------- | -------- | ---------------------------------------------- | | `--tenant` | `TENANT_ID` | yes | The tenant to act in (env `BETTER_IAM_TENANT`) | | `--peer-by` | `manager\|attribute:NAME` | no | How peers are grouped for outliers | ### check-invariants [#check-invariants] Evaluates a tenant's access invariants as a member and can fail the build when one is broken. ```bash better-iam check-invariants --config better-iam.config.mjs --tenant TENANT_ID [--fail-on-broken] [--url URL] [--profile NAME] ``` * **When:** in CI after `config-apply`, and before releases. * **Needs:** `--tenant` and `BETTER_IAM_TOKEN` with `iam:invariants:read`. * **Fails with:** `INVARIANTS_BROKEN` with `--fail-on-broken` when an invariant is broken or cannot be evaluated; `MISSING_ENV`; `ACCESS_DENIED`. * **Calls:** [`invariants.run`](/docs/reference/api/invariants#run). Flags: * `--fail-on-broken` exits non-zero, after printing the results, when any invariant failed or could not be evaluated (its resource or group no longer exists, for example). It prints `{ generatedAt, summary, results }`, with `passed`, `failed`, and `errors` counts and each invariant's violators. It records no `invariant:broken` events (that is `monitor-invariants`), so CI can run it as often as it likes. See [change safety](/docs/guides/governance/change-safety). ```bash BETTER_IAM_TOKEN="$CI_AUDITOR_KEY" better-iam check-invariants --config better-iam.config.mjs --tenant "$TENANT_ID" --fail-on-broken ``` | Flag | Value | Required | Description | | ------------------ | ----------- | -------- | -------------------------------------------------------- | | `--tenant` | `TENANT_ID` | yes | The tenant to act in (env `BETTER_IAM_TENANT`) | | `--fail-on-broken` | — | no | Exit non-zero when an invariant is broken or unevaluable | ### whoami [#whoami] Prints who the credential in `BETTER_IAM_TOKEN` acts as, and fails when it is no longer valid. ```bash better-iam whoami --config better-iam.config.mjs [--url URL] [--profile NAME] ``` * **When:** at the start of a CI job or script, to confirm which identity, tenant, and role it runs as, or to check that a session, API key, role session, or session token (opaque or JWT) has not been revoked. * **Needs:** `BETTER_IAM_TOKEN`, or a session saved by `login`. No permission; it records no audit event. * **Fails with:** `MISSING_ENV` without a token; `SESSION_EXPIRED` when the saved session has ended; `UNAUTHENTICATED` when the credential is invalid, expired, or revoked; `INVALID_ARGUMENT` for a flag it does not take (it takes `--config`, `--url`, `--profile`, `--format`, and `--query`). * **Calls:** [`sts.getCallerIdentity`](/docs/reference/api/sts#getcalleridentity). It prints the identity and its tenant, the tenant the session acts in, the session kind and id, the format, MFA, issue, sign-in, and expiry times, and, when they apply, the role, trust, source tenant, session name, source identity, session tags, JWT audiences, web identity, and impersonator. Hashes, policies, and authority ids are never included. ```bash BETTER_IAM_TOKEN="$ROLE_TOKEN" better-iam whoami --config better-iam.config.mjs | jq '{sessionKind, roleId, expiresAt}' ``` ### can [#can] Checks whether the session or API key may perform an action on a resource. ```bash better-iam can --config better-iam.config.mjs ACTION RESOURCE --tenant TENANT_ID [--url URL] [--profile NAME] ``` * **When:** to debug a denial, or in a script that branches on access before it does something. * **Needs:** `BETTER_IAM_TOKEN` or a saved session, and `--tenant` (or `BETTER_IAM_TENANT`, or the saved session's). * **Fails with:** `ACCESS_DENIED` when the answer is no (after printing the decision); `INVALID_ARGUMENT` for a resource that is not `type/id` or `type:id`. * **Calls:** [`authorize`](/docs/reference/api#authorize). It prints the decision with its reason and exits 0 only when allowed, so `if better-iam can …; then` works. The check is recorded like any other authorization decision. ```bash better-iam can documents:write document/d1 --tenant "$TENANT_ID" ``` | Flag | Value | Required | Description | | ---------- | ----------- | -------- | ---------------------------------------------- | | `--tenant` | `TENANT_ID` | yes | The tenant to act in (env `BETTER_IAM_TENANT`) | ### explain [#explain] Shows why another identity would be allowed or denied an action, without signing in as them. ```bash better-iam explain --config better-iam.config.mjs ACTION RESOURCE --tenant TENANT_ID --identity ID|EMAIL [--assume-mfa] [--url URL] [--profile NAME] ``` * **When:** when someone reports a denial, or before granting access, to see which statement decides. * **Needs:** a token with `iam:policies:simulate`, `--tenant`, and `--identity` (an ID or an email). * **Fails with:** `NOT_FOUND` when no single identity has the email; `ACCESS_DENIED`. * **Calls:** [`policies.simulate`](/docs/reference/api/policies#simulate). Flags: * `--identity ID|EMAIL` is the person or service account to explain. * `--assume-mfa` evaluates as if they had signed in with MFA. ```bash better-iam explain documents:write document/d1 --identity alice@acme.test --tenant "$TENANT_ID" ``` | Flag | Value | Required | Description | | -------------- | ----------- | -------- | ---------------------------------------------- | | `--tenant` | `TENANT_ID` | yes | The tenant to act in (env `BETTER_IAM_TENANT`) | | `--identity` | `ID\|EMAIL` | yes | Whose decision to explain | | `--assume-mfa` | — | no | Evaluate as an MFA session | ### who-can [#who-can] Lists every active identity that could perform an action on a resource, with the reason. ```bash better-iam who-can --config better-iam.config.mjs ACTION RESOURCE --tenant TENANT_ID [--kind user|service] [--assume-mfa] [--limit N] [--url URL] [--profile NAME] ``` * **When:** in access reviews and audits, and before deleting or sharing a sensitive resource. * **Needs:** a token with `iam:policies:simulate` and `--tenant`. * **Fails with:** `INVALID_ACTION` for an action the catalog does not know; `ACCESS_DENIED`. * **Calls:** [`policies.whoCan`](/docs/reference/api/policies#whocan). Flags: * `--kind user|service` lists only people or only service accounts. * `--assume-mfa` evaluates everyone as if signed in with MFA. * `--limit N` caps the list (default 100). Root administrators are not listed: their override applies everywhere. ```bash better-iam who-can documents:delete document/d1 --tenant "$TENANT_ID" --format table ``` | Flag | Value | Required | Description | | -------------- | --------------- | -------- | ---------------------------------------------- | | `--tenant` | `TENANT_ID` | yes | The tenant to act in (env `BETTER_IAM_TENANT`) | | `--kind` | `user\|service` | no | Only people or only service accounts | | `--assume-mfa` | — | no | Evaluate as MFA sessions | | `--limit` | `N` | no | Most identities listed (default `100`) | ### api [#api] Calls any method of the HTTP API as the session or API key and prints the result. ```bash better-iam api GROUP.METHOD [key=value ...] [--data JSON|@FILE|-] [--tenant TENANT_ID] [--list] [--url URL] [--profile NAME] ``` * **When:** for one-off administration from a terminal, and for scripts that need a method no dedicated command covers. * **Needs:** `BETTER_IAM_TOKEN` or a saved session with the method's own permission; public methods such as `tenants.lookup` need none. * **Fails with:** whatever the method fails with (`ACCESS_DENIED`, `INVALID_INPUT`, `NOT_FOUND` for an unknown method); `INVALID_ARGUMENT` for a malformed route or input item; `MISSING_ENV` without a token. * **Calls:** `POST {basePath}/{group}/{method}`, the same routes and checks as the [HTTP API](/docs/reference/api). Flags: * `--data JSON|@FILE|-` gives the whole request body, from the argument, a JSON file, or standard input. * `key=value` items add fields: `name=Admin` (string), `limit:=10` and `actions:='["a"]'` (JSON), `document:=@policy.json` (a JSON file), `content=@terms.md` (a file's text), `resource.type=doc` (nested), and `actions[]=read` (append). * `--tenant ID` fills `tenantId` when the input has none (also `BETTER_IAM_TENANT` or the saved session's tenant). * `--list [GROUP]` lists every route, or one group's, with whether it needs a credential. Top-level routes are `authorize`, `authorizeMany`, and `listAccessible`; plugin endpoints are `plugins/{id}/{path}`. Locally the call has no request-size limit; against `--url` it goes over HTTP like any client. ```bash better-iam api roles.create name=Reader permissions:='["documents:read"]' --tenant "$TENANT_ID" --query id ``` | Flag | Value | Required | Description | | ---------- | ---------------- | -------- | ----------------------------------------------------------------- | | `--data` | `JSON\|@FILE\|-` | no | Request body as JSON, a JSON file, or standard input | | `--tenant` | `TENANT_ID` | no | tenantId for inputs that do not set one (env `BETTER_IAM_TENANT`) | | `--list` | — | no | List routes (optionally of one group) instead of calling one | ### login [#login] Signs in once and saves the session, so later member commands need no `BETTER_IAM_TOKEN`. ```bash better-iam login [--config better-iam.config.mjs | --url URL] [--tenant TENANT_ID | --org SLUG] [--email EMAIL] [--email-code] [--with-token] [--profile NAME] ``` * **When:** at the start of a terminal session against a deployment, or once per CI job with `--with-token`. * **Needs:** `--tenant` or `--org`, `--email` (or a prompt), and the password from `BETTER_IAM_PASSWORD` or a hidden prompt; the MFA code from `BETTER_IAM_MFA_CODE` or a prompt. * **Fails with:** `INVALID_CREDENTIALS`; `MFA_ENROLLMENT_REQUIRED` when the account must enroll an authenticator first; `MISSING_ENV` when there is no password and no terminal to ask in; `PROFILE_IN_USE` when the profile holds a session for another deployment. * **Calls:** [`auth.signIn`](/docs/reference/api/auth#signin), then `auth.verifyMfa` and [`sts.getCallerIdentity`](/docs/reference/api/sts#getcalleridentity). Flags: * `--url URL` signs in to a running server; without it, the session is issued through the configuration. * `--tenant ID` or `--org SLUG` names the organization. * `--email-code` has a one-time code emailed instead of using an authenticator, when the organization allows it. * `--with-token` saves a token read from standard input (an API key, or a session from elsewhere) after checking it. * `--profile NAME` saves under that name (default: the current profile, or `default`) and makes it current. Without it, `login` refuses with `PROFILE_IN_USE` to replace a profile that holds a session for another deployment. A saved session is only ever used with the deployment that issued it: with `--url` or `--config` naming another one, member commands fail with `MISSING_ENV` and say which deployment the session belongs to. `--url` must use `https://` except for `localhost`. The session is saved in `~/.config/better-iam/credentials.json` (`%APPDATA%\better-iam\credentials.json` on Windows, or `BETTER_IAM_CREDENTIALS`) with owner-only permissions, together with the server or configuration and the tenant, so later commands need neither `--url` nor `--tenant`. Passwords and codes never appear in arguments or in the file. ```bash echo "$CI_API_KEY" | better-iam login --with-token --url https://iam.example.com --profile ci ``` | Flag | Value | Required | Description | | -------------- | ----------- | -------- | --------------------------------------------------------------- | | `--tenant` | `TENANT_ID` | no | Organization to sign in to (env `BETTER_IAM_TENANT`) | | `--org` | `SLUG` | no | Organization to sign in to, by its slug | | `--email` | `EMAIL` | no | Account email (prompted when absent) (env `BETTER_IAM_EMAIL`) | | `--email-code` | — | no | Email a one-time code for MFA instead of using an authenticator | | `--with-token` | — | no | Save a token read from standard input instead of signing in | ### logout [#logout] Signs a saved session out on the server and forgets it. ```bash better-iam logout --config better-iam.config.mjs [--url URL] [--profile NAME] ``` * **When:** when you are done with a deployment, or to replace a session. * **Needs:** a saved profile (`--profile`, default the current one). * **Fails with:** `NOT_FOUND` when no profile has that name. * **Calls:** [`auth.signOut`](/docs/reference/api/auth#signout) for user sessions. API keys and other machine credentials saved with `--with-token` are only forgotten, never revoked. The profile is removed even when the session had already ended. ```bash better-iam logout --profile ci ``` ### profiles [#profiles] Lists the sessions saved by `login`, or chooses or removes one. ```bash better-iam profiles [use NAME | remove NAME] ``` * **When:** to see which identity and deployment commands will act as, or to switch between them. * **Needs:** nothing; it never prints tokens. * **Fails with:** `NOT_FOUND` for an unknown profile; `INVALID_ARGUMENT` for a name with other characters than letters, digits, dot, dash, and underscore. `profiles` prints a table of names, servers or configurations, tenants, identities, and expiry, marking the current profile; `profiles use NAME` makes one current, and `profiles remove NAME` forgets one without signing it out. For a single command, `--profile NAME` or `BETTER_IAM_PROFILE` picks another profile, and `BETTER_IAM_TOKEN` bypasses profiles entirely. ```bash better-iam profiles use staging ``` ### token [#token] Prints the token member commands would act as, for other tools and scripts. ```bash better-iam token --config better-iam.config.mjs [--url URL] [--profile NAME] ``` * **When:** to hand the saved session to a tool that reads `BETTER_IAM_TOKEN` or an `Authorization` header. * **Needs:** `BETTER_IAM_TOKEN` or a saved session. * **Fails with:** `MISSING_ENV` without either; `SESSION_EXPIRED` when the saved session has ended. The output is a credential: keep it out of logs and shell history. ```bash export BETTER_IAM_TOKEN="$(better-iam token)" ``` ### completion [#completion] Prints a shell completion script for every command, flag, and fixed flag value. ```bash better-iam completion SHELL ``` * **When:** once, from your shell profile. * **Needs:** nothing; project commands from a configuration it finds are included. * **Fails with:** `INVALID_ARGUMENT` for a shell other than `bash`, `zsh`, `fish`, or `powershell`. ```bash eval "$(better-iam completion bash)" ``` # Error codes (/docs/reference/errors) > What each of the 144 Better IAM error codes means, why it happens, and how to handle it, grouped by HTTP status. Every failure in Better IAM is an `IamError` with three fields: a stable `code` such as `ACCESS_DENIED`, a `message` written for people, and an HTTP `status`. Codes are part of the public contract and do not change between releases. Messages may be reworded at any time and can contain names from your data, so show them to people but never parse them: branch on `code`, never on `message`. Over HTTP a refused call answers with that status and the body `{ "error": { "code": "ACCESS_DENIED", "message": "Access denied" } }`. A `RATE_LIMITED` answer also carries `retryAfterMs` in the body and a `Retry-After` header in seconds. An exception that is not an `IamError` becomes `500 INTERNAL_ERROR` with a generic message, so internals never leak. The [browser client](/docs/frameworks/client#errors) rethrows the envelope as `IamClientError` with the same `code` and `status`, plus `retryAfterMs` and, when you create the client with `requestId`, the `requestId` it sent as `X-Request-Id`, which you can quote in support tickets and find on the server's `http` spans. The [CLI](/docs/reference/cli) prints `CODE: message` to standard error and exits with status 1. Each code below says what went wrong, why it happens, and what the caller should do; codes are grouped by HTTP status. On the server, catch `IamError` and compare its `code`: ```ts import { IamError } from 'better-iam'; try { await iam.require({ headers, tenantId, action: 'documents:write', resource: { type: 'document', id } }); } catch (error) { if (error instanceof IamError && error.code === 'ACCESS_DENIED') return forbidden(); if (error instanceof IamError && error.code === 'RATE_LIMITED') return retryLater(error); throw error; } ``` ## Handling errors in a UI [#handling-errors-in-a-ui] Map codes to what the person should do next, not to messages. A few groups cover most screens: sign in again (`UNAUTHENTICATED`, `SESSION_NETWORK_MISMATCH`), step up (`MFA_REQUIRED`, `RECENT_AUTH_REQUIRED`), fix the form (`INVALID_INPUT`, `INVALID_CREDENTIALS`, `WEAK_PASSWORD`, shown next to the field), wait (`RATE_LIMITED`), not allowed (`ACCESS_DENIED`, where you can offer to request access), and reload because something changed (`CONFLICT`, `VERSION_CONFLICT`, `INVALID_TRANSITION`). Show anything else as a generic failure with the request ID. The Next.js, NestJS, SvelteKit, Nuxt, and Node middleware integrations already treat `UNAUTHENTICATED`, `MFA_REQUIRED`, `EMAIL_UNVERIFIED`, `TENANT_INACTIVE`, and `TENANT_UNAVAILABLE` as a signed-out request (Next.js also the network refusals `SESSION_NETWORK_MISMATCH`, `IP_BLOCKED`, and `IP_NOT_ALLOWED`). ```ts import { IamClientError } from 'better-iam/client'; type NextStep = | { kind: 'sign-in' } | { kind: 'step-up'; code: string } | { kind: 'wait'; ms: number } | { kind: 'message'; text: string }; /** Decides what the UI does after a failed call. */ export function nextStep(error: unknown): NextStep { if (!(error instanceof IamClientError)) throw error; // a network failure or an aborted call switch (error.code) { case 'UNAUTHENTICATED': case 'SESSION_NETWORK_MISMATCH': return { kind: 'sign-in' }; case 'MFA_REQUIRED': case 'RECENT_AUTH_REQUIRED': return { kind: 'step-up', code: error.code }; case 'RATE_LIMITED': return { kind: 'wait', ms: error.retryAfterMs ?? 60_000 }; case 'ACCESS_DENIED': return { kind: 'message', text: 'You do not have permission to do this.' }; case 'CONFLICT': case 'VERSION_CONFLICT': return { kind: 'message', text: 'This changed in the meantime. Reload and try again.' }; default: // requestId is set when the client was created with { requestId: true }. return { kind: 'message', text: `Something went wrong (reference ${error.requestId ?? 'none'}).` }; } } ``` ## INVALID\_ARGUMENT [#invalid_argument] A `better-iam` command received a flag it does not accept, a missing value, or a value out of range. The message names the problem, for example a required `--tenant` or `--output`, a flag used with the wrong command, or a number outside its allowed range; the command stops before it changes anything. **How to fix:** correct the flags using the command's usage line in the [CLI reference](/docs/reference/cli). ## INVALID\_COMMAND [#invalid_command] The CLI does not know the command you typed. **How to fix:** run `better-iam help` for the list of commands, or check the [CLI reference](/docs/reference/cli). ## 400 Bad request [#400-bad-request] The request itself is the problem: an input failed validation, or the operation is not allowed in the current state of the record. Fix the input or the order of operations; retrying the same request will fail the same way. | Code | Meaning | | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | [`AUDIT_ARCHIVE_FAILED`](#audit_archive_failed) | The `audit-archive` command could not archive the audit events of one or more tenants. | | [`AUDIT_ARCHIVE_INVALID`](#audit_archive_invalid) | The `audit-verify-archive` command found that a tenant's archived audit chain does not verify. | | [`AUDIT_CHAIN_BROKEN`](#audit_chain_broken) | The `audit-verify` command found that a tenant's audit chain does not verify. | | [`BREACHED_PASSWORD`](#breached_password) | The new password appears in a known data breach. | | [`CONFIG_DRIFT`](#config_drift) | `config-plan --fail-on-drift` found differences between the tenant and the configuration file. | | [`CONFIG_EXISTS`](#config_exists) | `better-iam init` found an existing configuration file and left it untouched. | | [`CONFIG_WARNINGS`](#config_warnings) | … unresolved reference(s) | | [`CREDENTIAL_CHAINING_DISABLED`](#credential_chaining_disabled) | A session token cannot be minted from a credential that is itself temporary. | | [`DELIVERY_REQUIRED`](#delivery_required) | The operation sends email, but the deployment has no email delivery callback. | | [`DOCTOR_FINDINGS`](#doctor_findings) | `doctor --strict` found problems more serious than informational notes. | | [`DOMAIN_NOT_ALLOWED`](#domain_not_allowed) | The domain belongs to a shared mailbox provider, so no organization can claim it. | | [`FINDINGS`](#findings) | `analyze --fail-on` found access findings at or above the chosen severity. | | [`HOSTNAME_NOT_ALLOWED`](#hostname_not_allowed) | The hostname belongs to the deployment itself, so no organization can claim it. | | [`INVALID_ACTION`](#invalid_action) | The action name is not in the catalog, or a tenant-defined action is malformed. | | [`INVALID_CONFIG`](#invalid_config) | The server, a storage adapter, or the CLI was given an invalid configuration. | | [`INVALID_CREDENTIAL`](#invalid_credential) | The credential id passed to a `credentials` method does not refer to an API key. | | [`INVALID_FILTER`](#invalid_filter) | A storage query was given an invalid filter, ordering, or pagination. | | [`INVALID_POLICY`](#invalid_policy) | A policy document or access-package rule does not follow the policy grammar. | | [`INVALID_RECORD`](#invalid_record) | A record written to storage is not plain JSON or breaks a storage limit. | | [`INVALID_REQUEST`](#invalid_request) | A policy evaluation input or an HTTP request target could not be understood. | | [`INVALID_RESOURCE_TYPE`](#invalid_resource_type) | The resource type is unknown, reserved, or cannot be used this way. | | [`INVALID_SEALED_VALUE`](#invalid_sealed_value) | A stored secret could not be decrypted with the configured secrets. | | [`INVALID_SPONSOR`](#invalid_sponsor) | The AI agent's sponsor is missing or is not an active person of the agent's tenant. | | [`INVALID_TICKET`](#invalid_ticket) | The inference ticket given to `inference.record` is unknown, already used, expired, or its caller no longer exists. | | [`INVARIANTS_BROKEN`](#invariants_broken) | `check-invariants --fail-on-broken` found invariants that are broken or cannot be evaluated. | | [`INVITATION_INVALID`](#invitation_invalid) | The invitation link is invalid, expired, already used, or revoked. | | [`LINKING_DISABLED`](#linking_disabled) | Account linking is turned off on this deployment. | | [`MAX_DEPTH`](#max_depth) | Creating or moving this tenant would make the hierarchy deeper than allowed. | | [`MISSING_ENV`](#missing_env) | A CLI command needs an environment variable that is not set. | | [`NOT_INITIALIZED`](#not_initialized) | Root recovery was attempted before the platform was bootstrapped. | | [`OAUTH_CALLBACK`](#oauth_callback) | The OAuth sign-in callback arrived at a URL that does not match the connection's redirect URI. | | [`OAUTH_STATE`](#oauth_state) | The OAuth sign-in could not be matched to the browser that started it. | | [`PASSWORD_REUSED`](#password_reused) | The new password matches one of the person's recent passwords. | | [`PROTECTED_IDENTITY`](#protected_identity) | A root administrator cannot be linked during organization onboarding. | | [`RECONCILE_ATTENTION`](#reconcile_attention) | `reconcile --fail-on-attention` found access-package rules that need attention. | | [`RESOURCE_RESOLVER_REQUIRED`](#resource_resolver_required) | An authorization check names an application resource type, but no `resolveResource` callback is configured. | | [`ROLE_CHAINING_DISABLED`](#role_chaining_disabled) | A role cannot be assumed from a session that is itself an assumed role. | | [`ROLLBACK`](#rollback) | A deliberate signal inside the storage conformance suite that discards a test transaction. | | [`SNAPSHOT_INVALID`](#snapshot_invalid) | The file given to `store-import` is not a valid Better IAM snapshot. | | [`SNAPSHOT_TRUNCATED`](#snapshot_truncated) | The snapshot ended early, or its record counts do not match its trailer. | | [`UNREADABLE_SECRETS`](#unreadable_secrets) | `rotate-secrets` found stored values that none of the configured secrets can open. | | [`VERIFIED_EMAIL_REQUIRED`](#verified_email_required) | A first sign-in through an external provider did not come with a verified email address. | | [`WEAK_PASSWORD`](#weak_password) | The new password does not meet the password rules. | | [`WEAK_TRUST_CONDITIONS`](#weak_trust_conditions) | A web-identity trust must name exactly which workload may use it, and these conditions do not. | ### AUDIT\_ARCHIVE\_FAILED [#audit_archive_failed] The `audit-archive` command could not archive the audit events of one or more tenants. The command prints its full result first and names each failed tenant with the error code it hit. Tenants that succeeded are archived, and every tenant keeps its own archive position. **How to fix:** resolve the listed errors (often the archive sink's storage or permissions) and run the command again; it continues from each tenant's position ([continuous audit archiving](/docs/operations/jobs#continuous-audit-archiving)). HTTP `400` · thrown by `@better-iam/cli` Example messages: * … tenant(s) could not be archived: $\{result.failed.map((failure) => ### AUDIT\_ARCHIVE\_INVALID [#audit_archive_invalid] The `audit-verify-archive` command found that a tenant's archived audit chain does not verify. The command checks the archive files alone, without the database: every sequence must appear once, hashes are recomputed, and links must be intact. It also fails when two archived copies of the same sequence disagree, and prints the result, including the failure reason and sequence, before it exits. **How to fix:** treat it as a possible tampering or data-loss incident, and compare the failing range with the database (`audit-verify`) and your backups ([audit chain](/docs/guides/events/audit-chain)). HTTP `400` · thrown by `@better-iam/cli` ### AUDIT\_CHAIN\_BROKEN [#audit_chain_broken] The `audit-verify` command found that a tenant's audit chain does not verify. Each audit event carries a hash of the one before it, so a changed, removed, or reordered event breaks the chain. The command prints the verification result, including where it failed, before it exits. **How to fix:** treat it as a possible tampering or data-loss incident: find the failing sequence in the output, compare it with your archive or backups, and review who can write to the database ([audit chain](/docs/guides/events/audit-chain)). HTTP `400` · thrown by `@better-iam/cli` Example messages: * The audit chain does not verify ### BREACHED\_PASSWORD [#breached_password] The new password appears in a known data breach. It is raised wherever a password is set (sign-up, password change, password reset, and other flows that set one) when the deployment screens passwords with `authentication.passwordPolicy.isBreached`, for example the built-in `pwnedPasswords()` client. **How to fix:** ask the person to choose a different password, and show the message next to the password field ([password screening](/docs/guides/authentication/sign-in-methods#password-screening)). HTTP `400` · thrown by `@better-iam/auth` Example messages: * This password appears in a known data breach; choose a different one ### CONFIG\_DRIFT [#config_drift] `config-plan --fail-on-drift` found differences between the tenant and the configuration file. The command prints the full plan first; the count covers the items it would create, update, or delete. The flag exists so a CI job fails when someone changed production outside the reviewed file. **How to fix:** review the plan, then apply the file with `config-apply` or update the file to the intended state ([configuration as code](/docs/guides/privileged-access/config-as-code)). HTTP `400` · thrown by `@better-iam/cli` Example messages: * The tenant differs from the configuration in … item(s) ### CONFIG\_EXISTS [#config_exists] `better-iam init` found an existing configuration file and left it untouched. **How to fix:** edit the existing file, or pass `--config` with a new path to generate a fresh one. HTTP `400` · thrown by `@better-iam/cli` Example messages: * Configuration already exists; it was not overwritten ### CONFIG\_WARNINGS [#config_warnings] HTTP `400` · thrown by `@better-iam/cli` Example messages: * … unresolved reference(s) ### CREDENTIAL\_CHAINING\_DISABLED [#credential_chaining_disabled] A session token cannot be minted from a credential that is itself temporary. `sts.getSessionToken` accepts only a signed-in session or an API key as its source. Role sessions and other session tokens are refused, so a temporary credential can never be renewed or extended by deriving a new one from it. **How to fix:** call `sts.getSessionToken` with the person's own session or the service account's API key ([sts](/docs/reference/api/sts#getsessiontoken)). HTTP `400` · thrown by `@better-iam/server` Example messages: * Temporary credentials cannot mint session tokens ### DELIVERY\_REQUIRED [#delivery_required] The operation sends email, but the deployment has no email delivery callback. Organization and member invitations (`tenants.create`, `identities.invite`, and their resend calls), certification reminders (`certifications.remind`), the access digest, and expiry reminders enqueue email and refuse to run without `authentication.sendEmail`. **How to fix:** configure `sendEmail` in the server options ([configuration](/docs/operations/deployment/configuration)); in tests, a callback that records messages is enough. HTTP `400` · thrown by `@better-iam/server` Example messages: * Reminders require an email delivery callback * Member invitations require an email delivery callback * Organization invitations require an email delivery callback * The access digest requires an email delivery callback * Expiry reminders require an email delivery callback ### DOCTOR\_FINDINGS [#doctor_findings] `doctor --strict` found problems more serious than informational notes. `doctor` checks the schema, durability settings, secrets, delivery transports, and scheduled jobs, and prints every finding; with `--strict`, any finding above `info` severity fails the run. **How to fix:** fix each check named in the output, or run without `--strict` while you work through them ([doctor](/docs/operations/storage#doctor)). HTTP `400` · thrown by `@better-iam/cli` Example messages: * … finding(s): … ### DOMAIN\_NOT\_ALLOWED [#domain_not_allowed] The domain belongs to a shared mailbox provider, so no organization can claim it. `domains.add` refuses consumer email domains (a default block list, replaced by the `domains.blockedDomains` option), because verifying one would route everyone with such an address to a single organization. **How to fix:** claim the organization's own domain instead. HTTP `400` · thrown by `@better-iam/server` Example messages: * Shared mailbox providers cannot be claimed by an organization ### FINDINGS [#findings] `analyze --fail-on` found access findings at or above the chosen severity. The command prints the report first; the count covers unsuppressed findings at the `--fail-on` level or higher, so a CI job fails when new risk appears. **How to fix:** fix the findings, or record accepted ones with `analysis.suppress` so they stop failing the run ([scan for risky configuration](/docs/guides/authorization/reviews#scan-for-risky-configuration)). HTTP `400` · thrown by `@better-iam/cli` Example messages: * … finding(s) at or above … severity ### HOSTNAME\_NOT\_ALLOWED [#hostname_not_allowed] The hostname belongs to the deployment itself, so no organization can claim it. `hostnames.add` refuses the deployment's base URL host, its trusted origins, and every name in its organization subdomain space (anything matching or under a `hosts.patterns` template), because those addresses already route to the deployment or to other organizations. **How to fix:** claim a hostname on the organization's own domain, such as `login.acme.com`. HTTP `400` · thrown by `@better-iam/server` Example messages: * This hostname belongs to the deployment itself and cannot be claimed ### INVALID\_ACTION [#invalid_action] The action name is not in the catalog, or a tenant-defined action is malformed. Policy documents, `listAccessible`, access paths, invariants, `policies.whoCan`, and impact previews accept only actions the catalog knows (wildcards excepted). `actions.register` needs the `{resourceType}:{verb}` form under a tenant-defined resource type that is registered first. **How to fix:** check the spelling, then declare the action in `permissions.actions` or register it ([catalog](/docs/guides/authorization/catalog)). HTTP `400` · thrown by `@better-iam/server` Example messages: * Unknown action … * Tenant actions must be \{resourceType}:\{verb} under a tenant-defined resource type * Register resource type … before its actions ### INVALID\_CONFIG [#invalid_config] The server, a storage adapter, or the CLI was given an invalid configuration. It surfaces at startup: when you call `betterIam()`, create an adapter, or when a CLI command loads your configuration file. Examples are a missing `database`, a plain-HTTP `baseURL` outside localhost, an unsupported database URL, or email features enabled without `sendEmail`; the browser client raises it too (with status `0`) for a bad `baseURL` or `basePath`. **How to fix:** correct the option the message names ([configuration](/docs/operations/deployment/configuration)). HTTP `400` · thrown by `@better-iam/adapter-libsql` , `@better-iam/adapter-postgres` , `@better-iam/adapter-sqlite` , `@better-iam/auth` , `@better-iam/cli` , `@better-iam/server` Example messages: * libSQL requires a url: :memory:, file:, libsql:, https:, or wss: * Unsupported libSQL URL scheme * busyTimeoutMs must be between 0 and 60000 * A PostgreSQL connection string is required * Invalid PostgreSQL pool size or lock timeout * SQLite requires a filename or :memory:; URI filenames are unsupported ### INVALID\_CREDENTIAL [#invalid_credential] The credential id passed to a `credentials` method does not refer to an API key. `credentials.get`, `credentials.update`, `credentials.revoke`, and `credentials.rotate` manage service-account API keys only, so the id of a user session or temporary credential is refused. **How to fix:** pass an API key id from `credentials.list`; end user sessions with `identities.revokeSessions` instead. HTTP `400` · thrown by `@better-iam/server` Example messages: * Not an API key credential * Only API keys can rotate ### INVALID\_FILTER [#invalid_filter] A storage query was given an invalid filter, ordering, or pagination. It comes from `IamStore.find` and `findOrdered`: the filter must be a plain JSON object, `limit` and `offset` nonnegative safe integers, the `after` cursor an id, and ordering needs a top-level field, `asc` or `desc`, and finite bounds. You meet it when a plugin or adapter works with the store directly. **How to fix:** correct the query arguments ([adapter contract](/docs/operations/extensions#adapter-contract)). HTTP `400` · thrown by `@better-iam/core` Example messages: * Field names in SQL conditions must be identifiers * SQLite adapters do not evaluate JSON conditions * Filter must be a JSON object * Pagination must use nonnegative safe integers * The pagination cursor must be an id * Order options are required ### INVALID\_POLICY [#invalid_policy] A policy document or access-package rule does not follow the policy grammar. Policies, boundaries, ceilings, API key session policies, and package rules are validated when saved; the message says what is wrong, and for package rules it starts with the path of the failing clause. **How to fix:** correct the document; [`analysis.lintPolicy`](/docs/reference/api/analysis#lintpolicy) checks one before you save it ([policies](/docs/guides/authorization/policies)). HTTP `400` · thrown by `@better-iam/core` , `@better-iam/server` Example messages: * …: … ### INVALID\_RECORD [#invalid_record] A record written to storage is not plain JSON or breaks a storage limit. A stored record must be a JSON object whose collection name, `id`, `tenantId`, and `uniqueKey` are nonempty strings of at most 512 UTF-8 bytes, nested at most 64 levels, without symbols, cycles, or unpaired surrogates, and under one million characters. You meet it when a plugin or your own code writes to `iam.store`. **How to fix:** store only plain JSON values within these limits ([adapter contract](/docs/operations/extensions#rules-every-adapter-must-keep)). HTTP `400` · thrown by `@better-iam/core` Example messages: * … must be a nonempty string of at most 512 UTF-8 bytes * … cannot contain unpaired surrogates * Record nesting exceeds 64 levels * Records must contain JSON data only * Circular records are not supported * Records cannot contain symbols ### INVALID\_REQUEST [#invalid_request] A policy evaluation input or an HTTP request target could not be understood. `evaluatePolicy` from `@better-iam/core` needs an action, a resource, and a `grants` array (with optional `boundaries` and `context`), and the Node.js handler refuses a request URL it cannot parse. **How to fix:** pass a complete evaluation input, or fix the client or proxy that produced the URL. HTTP `400` · thrown by `@better-iam/core` , `@better-iam/server` Example messages: * Invalid policy evaluation request * Invalid request target ### INVALID\_RESOURCE\_TYPE [#invalid_resource_type] The resource type is unknown, reserved, or cannot be used this way. `resourceTypes.register` refuses a name the platform already defines and a parent that is not an existing managed type. Registering resources, creating relationships, and policy documents need a known type, and only managed types hold registered resources; other types are resolved by your `resolveResource` callback. **How to fix:** declare or register the type first, and check whether it is managed ([resources and catalog](/docs/guides/concepts/resources-and-catalog)). HTTP `400` · thrown by `@better-iam/server` Example messages: * Resource type name is reserved or already defined by the platform * Parent must be an existing managed resource type * Unknown resource type * This resource type is resolved by the application, not registered with IAM * Unknown resource type … ### INVALID\_SEALED\_VALUE [#invalid_sealed_value] A stored secret could not be decrypted with the configured secrets. Better IAM seals sensitive values at rest (webhook signing secrets, queued delivery payloads, downstream SCIM tokens, and others) with the deployment `secret`. When that secret changed and the old one is not in `previousSecrets`, or the stored value was damaged, the value cannot be opened. **How to fix:** add the secret that sealed the value to `previousSecrets`, then run `rotate-secrets` to re-seal everything under the current secret ([secrets](/docs/operations/deployment/secrets)). HTTP `400` · thrown by `@better-iam/auth` , `@better-iam/scim` , `@better-iam/server` Example messages: * Invalid sealed value * Outbox payload is not sealed * The stored downstream token cannot be opened. * Webhook payload is incomplete ### INVALID\_SPONSOR [#invalid_sponsor] The AI agent's sponsor is missing or is not an active person of the agent's tenant. Every agent needs a sponsor, an active and unexpired person of the same tenant who answers for it. `agents.create` makes the caller the sponsor only when the caller is a person in their own session, so an API key or a temporary credential must name one with `sponsorId`. `agents.create` and `agents.update` refuse a sponsor who is a service account or another agent, disabled, deleted, expired, or in another tenant. **How to fix:** pass the `sponsorId` of an active member of the agent's tenant ([`agents.create`](/docs/reference/api/agents#create)). HTTP `400` · thrown by `@better-iam/server` Example messages: * An agent sponsor must be an active person of the same tenant * Name the person accountable for the agent (sponsorId) ### INVALID\_TICKET [#invalid_ticket] The inference ticket given to `inference.record` is unknown, already used, expired, or its caller no longer exists. An allowed [`inference.check`](/docs/reference/api/inference#check) returns a single-use ticket that is valid for one hour and names the caller, their session, and the model. An external gateway redeems it once with the call's token counts; a ticket of another tenant is refused too. **How to fix:** meter each call once, within the hour, with the ticket its own check returned; when a ticket is lost, the call cannot be metered through `record`, so check again before the next call. HTTP `400` · thrown by `@better-iam/server` Example messages: * Unknown, used or expired inference ticket ### INVARIANTS\_BROKEN [#invariants_broken] `check-invariants --fail-on-broken` found invariants that are broken or cannot be evaluated. The command prints the full run first, then fails so a CI job or scheduler notices. **How to fix:** review the violations and errors in the output, then correct the access or the invariant ([access invariants](/docs/guides/governance/change-safety#access-invariants)). HTTP `400` · thrown by `@better-iam/cli` Example messages: * … broken and … unevaluable invariant(s) ### INVITATION\_INVALID [#invitation_invalid] The invitation link is invalid, expired, already used, or revoked. `tenants.acceptInvitation` and `identities.acceptInvitation` refuse a token that matches no open invitation, an organization that is no longer pending, and an invitation whose inviter's grant authority was revoked after it was sent. **How to fix:** ask an administrator to send a new invitation with `tenants.resendInvitation` or `identities.resendInvitation`. HTTP `400` · thrown by `@better-iam/server` Example messages: * Invitation is invalid * Invitation authority revoked ### LINKING\_DISABLED [#linking_disabled] Account linking is turned off on this deployment. `links.create`, and accepting an organization invitation with a link to an existing account, need `onboarding: { mode: 'linked' }` in the server options. **How to fix:** enable linked onboarding in your [configuration](/docs/operations/deployment/configuration), or accept the invitation without linking. HTTP `400` · thrown by `@better-iam/server` Example messages: * Linked onboarding is disabled * Account linking disabled ### MAX\_DEPTH [#max_depth] Creating or moving this tenant would make the hierarchy deeper than allowed. `tenants.create` and `tenants.reparent` enforce `hierarchy.maxDepth`, eight levels by default. **How to fix:** attach the tenant higher in the tree, or raise `maxDepth` in your configuration if deeper nesting is intended. HTTP `400` · thrown by `@better-iam/server` Example messages: * Maximum tenant depth reached ### MISSING\_ENV [#missing_env] A CLI command needs an environment variable that is not set. Commands that act as a member (`config-export`, `config-plan`, `config-apply`, `analyze`, `report`, `check-invariants`, `mine-roles`) read a session token or API key from `BETTER_IAM_TOKEN`; `bootstrap` and `recover-root` read `BETTER_IAM_ROOT_EMAIL` and `BETTER_IAM_ROOT_PASSWORD`. Secrets never go on the command line. **How to fix:** export the variable the message names and run the command again. HTTP `400` · thrown by `@better-iam/cli` Example messages: * Set BETTER\_IAM\_DATABASE\_URL * Set BETTER\_IAM\_SECRET ### NOT\_INITIALIZED [#not_initialized] Root recovery was attempted before the platform was bootstrapped. [`recoverRoot`](/docs/reference/api#recoverroot) and `better-iam recover-root` add a root administrator to the existing root tenant, so one must exist. **How to fix:** run `bootstrap` first ([recovering root access](/docs/guides/authentication/recovery#recovering-root-access)). HTTP `400` · thrown by `@better-iam/server` Example messages: * Bootstrap first ### OAUTH\_CALLBACK [#oauth_callback] The OAuth sign-in callback arrived at a URL that does not match the connection's redirect URI. The callback's origin and path must equal the connection's `redirectUri`, so a response meant for another route is never accepted. **How to fix:** make the redirect URI registered with the provider, the connection's `redirectUri`, and the route that handles the callback identical ([OAuth sign-in](/docs/federation/oauth-sign-in)). HTTP `400` · thrown by `@better-iam/oauth` Example messages: * Unexpected callback URL. ### OAUTH\_STATE [#oauth_state] The OAuth sign-in could not be matched to the browser that started it. The callback needs the `state` parameter and the browser-binding cookie set when the sign-in began. State that is missing, expired (after ten minutes), already used, or from another browser is refused, which blocks login CSRF and replayed callbacks. **How to fix:** start the sign-in again in the same browser, and check that the binding cookie reaches the callback route. HTTP `400` · thrown by `@better-iam/oauth` Example messages: * Missing login state. * Invalid, expired, or replayed login state. ### PASSWORD\_REUSED [#password_reused] The new password matches one of the person's recent passwords. A tenant's `authPolicy.passwordHistory` refuses the last that many passwords when a password is changed or reset. **How to fix:** ask the person to choose a password they have not used recently ([password rules](/docs/guides/authentication/tenant-policy#password-rules)). HTTP `400` · thrown by `@better-iam/auth` Example messages: * Password must differ from your last … password… ### PROTECTED\_IDENTITY [#protected_identity] A root administrator cannot be linked during organization onboarding. Accepting an organization invitation with linked onboarding refuses a link credential that belongs to a root administrator, so platform identities stay separate from customer organizations. **How to fix:** accept the invitation without linking, or link an ordinary account. HTTP `400` · thrown by `@better-iam/server` Example messages: * Root identities cannot link ### RECONCILE\_ATTENTION [#reconcile_attention] `reconcile --fail-on-attention` found access-package rules that need attention. The count covers package rules that failed or were suspended, runs stopped by the safety brake until someone confirms them, and tenants that could not be processed; the full result is printed first. **How to fix:** review the listed packages, fix their rules, and confirm braked changes with `reconcile --tenant ID --package ID --confirm` ([automatic assignment](/docs/guides/privileged-access/automatic-assignment)). HTTP `400` · thrown by `@better-iam/cli` Example messages: * … package rule problem(s) need attention ### RESOURCE\_RESOLVER\_REQUIRED [#resource_resolver_required] An authorization check names an application resource type, but no `resolveResource` callback is configured. Managed resource types are registered with IAM and read from its storage; for every other type Better IAM needs your `resolveResource` option to load the resource and its attributes. **How to fix:** configure `resolveResource`, or declare the type as managed and register its resources with `resources.register` ([resources and catalog](/docs/guides/concepts/resources-and-catalog)). HTTP `400` · thrown by `@better-iam/server` Example messages: * Configure resolveResource for application resources ### ROLE\_CHAINING\_DISABLED [#role_chaining_disabled] A role cannot be assumed from a session that is itself an assumed role. `roles.assume` refuses role sessions as the source, so temporary access can never be extended by chaining one role into another. A session token from `sts.getSessionToken` is accepted as a source, because it is the same identity with the same or fewer grants. **How to fix:** assume the role from the original user session or API key ([assumed roles](/docs/guides/authentication/sign-in-methods#assumed-roles)). HTTP `400` · thrown by `@better-iam/server` Example messages: * Role chaining is disabled ### ROLLBACK [#rollback] A deliberate signal inside the storage conformance suite that discards a test transaction. The suite throws it to prove that an adapter rolls back uncommitted writes; the API never returns it. **How to fix:** nothing, unless a conformance check around it fails, which means your adapter did not roll the transaction back as required ([conformance suite](/docs/operations/extensions#conformance-suite)). HTTP `400` · thrown by `@better-iam/core` Example messages: * discard ### SNAPSHOT\_INVALID [#snapshot_invalid] The file given to `store-import` is not a valid Better IAM snapshot. Every line must be a JSON object: a header naming the snapshot format and version, one record per line, and a trailer with counts. An unsupported version or content after the trailer is refused, and nothing is imported. **How to fix:** import a file written by `store-export`, unmodified ([snapshots](/docs/operations/storage#snapshots-and-moving-between-databases)). HTTP `400` · thrown by `@better-iam/core` Example messages: * Content after the trailer on line … * Line … is not JSON * Line … is not an object * Not a … version … snapshot * The trailer on line … has no counts * Line … is not a record entry ### SNAPSHOT\_TRUNCATED [#snapshot_truncated] The snapshot ended early, or its record counts do not match its trailer. `store-import` checks the trailer that `store-export` writes last; a missing trailer or a count mismatch means the file was cut off or edited, so nothing is imported. **How to fix:** export the snapshot again and copy it completely ([snapshots](/docs/operations/storage#snapshots-and-moving-between-databases)). HTTP `400` · thrown by `@better-iam/core` Example messages: * The snapshot has no trailer; nothing was imported * Record counts do not match the snapshot trailer; nothing was imported ### UNREADABLE\_SECRETS [#unreadable_secrets] `rotate-secrets` found stored values that none of the configured secrets can open. The command re-seals values encrypted with `previousSecrets` under the current `secret` and reports any it cannot open, which means the secret that sealed them is missing from the configuration. **How to fix:** add that secret to `previousSecrets` and run the command again; keep old secrets until it passes ([secrets](/docs/operations/deployment/secrets)). HTTP `400` · thrown by `@better-iam/cli` Example messages: * … stored value(s) open with no configured secret; add the secret that sealed them to previousSecrets ### VERIFIED\_EMAIL\_REQUIRED [#verified_email_required] A first sign-in through an external provider did not come with a verified email address. Enrolling a new account from an OAuth, OpenID Connect, or SAML sign-in needs an email the provider asserts as verified, because the account is created with that email already verified. **How to fix:** have the person verify their email at the provider, or configure the connection to release a verified email; a person with an existing account can link the provider to it instead ([first sign-in](/docs/federation/oauth-sign-in#first-sign-in-and-account-linking)). HTTP `400` · thrown by `@better-iam/server` Example messages: * Federation enrollment needs a verified email ### WEAK\_PASSWORD [#weak_password] The new password does not meet the password rules. Every password needs at least 12 characters. A tenant can add a longer minimum, required character classes, and a ban on the person's name or email; the built-in screen refuses common and keyboard-pattern passwords; and a custom `passwordPolicy.check` can add its own rule and message. **How to fix:** show the message next to the password field and let the person choose a stronger password ([password rules](/docs/guides/authentication/tenant-policy#password-rules)). HTTP `400` · thrown by `@better-iam/auth` Example messages: * Password must contain at least … characters in this organization * Password must mix at least … of: lowercase, uppercase, digits, symbols * Password must not contain your name or email address * Password is too common or predictable * Password must contain at least 12 characters ### WEAK\_TRUST\_CONDITIONS [#weak_trust_conditions] A web-identity trust must name exactly which workload may use it, and these conditions do not. Web-identity federation lets tokens from an OpenID Connect provider, such as a CI platform, be exchanged for a role session. Its trust conditions must pin the token's subject with a `StringEquals` or `StringLike` entry on `token.sub` whose values are nonempty and do not start with a wildcard; otherwise every workload the provider issues tokens for could assume the role. **How to fix:** add a `token.sub` condition naming the specific subject, for example one repository and branch ([web-identity federation](/docs/operations/deployment/configuration#web-identity-federation)). HTTP `400` · thrown by `@better-iam/server` Example messages: * Web identity trusts must pin token.sub with StringEquals or StringLike (no leading wildcard) ## 401 Unauthenticated [#401-unauthenticated] Better IAM could not establish who is calling, or the proof it was given is no longer good enough: the session expired or was revoked, the key is invalid, or a code was wrong. Send the person back through sign-in (or step-up) and retry. | Code | Meaning | | ------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | [`DELEGATION_TOKEN_INVALID`](#delegation_token_invalid) | A delegation token did not verify, or the delegation behind it has ended. | | [`INVALID_ASSERTION`](#invalid_assertion) | A stateless assertion failed verification. | | [`INVALID_CHALLENGE`](#invalid_challenge) | A one-time link, code, or sign-in step is wrong, expired, or already used. | | [`INVALID_CREDENTIALS`](#invalid_credentials) | The email and password, or the current password, are wrong, or the account cannot sign in. | | [`INVALID_MFA`](#invalid_mfa) | The authenticator code or recovery code is wrong, expired, or already used. | | [`INVALID_PASSKEY`](#invalid_passkey) | The passkey response could not be verified or does not belong to this account. | | [`INVALID_TOKEN`](#invalid_token) | The OAuth access token, or its DPoP proof, is missing, invalid, or expired. | | [`OAUTH_PROFILE`](#oauth_profile) | The identity provider's answer did not identify the person. | | [`SAML_INVALID`](#saml_invalid) | The SAML response could not be accepted. | | [`SESSION_NETWORK_MISMATCH`](#session_network_mismatch) | The session is bound to the network it was signed in from and was used from a different one. | | [`UNAUTHENTICATED`](#unauthenticated) | The request has no valid credential, or its credential has expired or been revoked. | ### DELEGATION\_TOKEN\_INVALID [#delegation_token_invalid] A delegation token did not verify, or the delegation behind it has ended. `iam.a2a.verifyDelegationToken` answers it (401) when the token is not a `biam-delegation+jwt` signed with the deployment's card keys, when its issuer, audience, organization, or times do not match, or when it lives longer than an hour. With `live: true` it also answers it when anything the token stands on has changed: * the delegation or one above it was revoked or has expired; * the person or an agent in the chain is no longer in good standing; * the acting agent's API key was revoked, or the agent no longer accepts delegation; * the audience left an agent's `tokenAudiences`; * a limit no longer allows a scope. It also answers it when a `replay` check refuses a token seen before. **How to fix:** the agent gets a new token with [`delegations.issueToken`](/docs/reference/api/delegations#issuetoken) for the right `audience`; when the delegation has ended, the person grants a new one. HTTP `401` · thrown by `@better-iam/server` ### INVALID\_ASSERTION [#invalid_assertion] A stateless assertion failed verification. `verifyAssertion` (and the Next.js edge and NestJS assertion helpers) refuses a token that is malformed, signed with another key, meant for another audience or issuer, expired, or not yet valid. **How to fix:** issue a fresh assertion with [`assertions.issue`](/docs/reference/api/assertions#issue) for the right audience, and verify with every current key from `iam.assertionKeys()` while a secret rotates ([stateless assertions](/docs/operations/security#stateless-assertions)). HTTP `401` · thrown by `@better-iam/server` ### INVALID\_CHALLENGE [#invalid_challenge] A one-time link, code, or sign-in step is wrong, expired, or already used. It covers email verification, password-reset, and email-change links, passwordless and phone verification codes, the sign-in challenge between the password and the second factor, MFA enrollment, and passkey ceremonies. A passwordless code also fails when the email or phone changed after it was sent. **How to fix:** start the step again to get a new link or code; retrying the same one fails the same way. HTTP `401` · thrown by `@better-iam/auth` Example messages: * Invalid verification code * Challenge is invalid or expired * MFA enrollment is invalid or expired * Invalid passkey challenge * Sign-in challenge is invalid or expired * Email has changed ### INVALID\_CREDENTIALS [#invalid_credentials] The email and password, or the current password, are wrong, or the account cannot sign in. `auth.signIn` gives the same answer for an unknown email, a wrong password, and a disabled account, so responses never reveal which accounts exist; `auth.changePassword` and `auth.reauthenticate` use it for a wrong password. Failed attempts count toward rate limits and are recorded. **How to fix:** show a generic "email or password is incorrect" message on the form and let the person retry or reset their password. HTTP `401` · thrown by `@better-iam/auth` Example messages: * Invalid current password * Invalid password * Authentication is unavailable for this account * Invalid email or password ### INVALID\_MFA [#invalid_mfa] The authenticator code or recovery code is wrong, expired, or already used. Authenticator (TOTP) codes must be six digits and each time step is accepted once, so a replayed code fails; emailed codes expire, and each recovery code works once. Failures count toward the sign-in rate limit. **How to fix:** enter a fresh code from the authenticator app or email, or an unused recovery code ([MFA](/docs/guides/authentication/mfa)). HTTP `401` · thrown by `@better-iam/auth` Example messages: * Invalid MFA code * Invalid or previously used MFA code * Invalid recovery code ### INVALID\_PASSKEY [#invalid_passkey] The passkey response could not be verified or does not belong to this account. Registration and sign-in responses are checked against the stored challenge, the relying party, user verification, and the registered credential; a passkey registered for another identity or organization is refused. **How to fix:** start the ceremony again and use a passkey registered for this account in this organization ([passkeys](/docs/guides/authentication/passkeys)). HTTP `401` · thrown by `@better-iam/auth` Example messages: * Passkey registration verification failed * Passkey verification failed * Passkey is not registered for this organization * Passkey user handle does not match this identity * Passkey does not belong to this tenant identity * Passkey authentication verification failed ### INVALID\_TOKEN [#invalid_token] The OAuth access token, or its DPoP proof, is missing, invalid, or expired. Resource servers built with `createAccessTokenVerifier` or `createResourceGuard` refuse a missing or expired token, a replayed or mismatched DPoP proof, and a sender-constrained token sent without its proof, and answer with a `WWW-Authenticate` challenge naming `invalid_token`. The browser client also raises it, before sending, for a configured bearer token with an invalid format. **How to fix:** obtain a new access token (refresh it or sign in again) and send it with the right scheme ([resource servers](/docs/federation/oauth-resource-servers)). HTTP `401` · thrown by `@better-iam/oauth` ### OAUTH\_PROFILE [#oauth_profile] The identity provider's answer did not identify the person. The sign-in fails when the profile request fails or returns something unusable, when the profile or OIDC ID token names no subject, or when a Microsoft ID token names no directory. **How to fix:** retry the sign-in; if it keeps failing, check the connection's provider settings, scopes, and any `mapProfile` function ([providers](/docs/federation/oauth-sign-in#providers)). HTTP `401` · thrown by `@better-iam/oauth` Example messages: * The Microsoft ID token names no directory. * Provider profile request failed. * Provider subject is missing. * Provider profile is invalid. * OIDC subject is missing. ### SAML\_INVALID [#saml_invalid] The SAML response could not be accepted. Better IAM rejects responses that are unsafe or malformed XML, fail the signature, issuer, audience, destination, recipient, or age checks, were already used, are unsolicited when the connection does not allow IdP-initiated sign-in, or lack a required encrypted assertion. The assertion consumer service answers every such failure the same way, so an attacker learns nothing from it. **How to fix:** start the sign-in again from your application; if it keeps failing, compare the identity provider's settings (entity ID, ACS URL, certificates, encryption) with the connection ([SAML](/docs/federation/saml)). HTTP `400` , `401` , `413` , `415` · thrown by `@better-iam/saml` Example messages: * Unsafe SAML document. * SAML response binding is invalid. * An encrypted assertion is required. * SAML subject recipient is invalid. * An unsolicited response cannot answer a request. * Invalid SAML response. ### SESSION\_NETWORK\_MISMATCH [#session_network_mismatch] The session is bound to the network it was signed in from and was used from a different one. A tenant's `authPolicy.bindSessionsToIp` makes a user session usable only from the client IP it was issued from, so a stolen session cookie or token does not work elsewhere; the refused attempt is recorded. Requests without a recorded client IP are not judged. **How to fix:** sign in again from the current network; the browser client calls its `onUnauthenticated` hook for this code ([binding sessions to their network](/docs/guides/authentication/tenant-policy#binding-sessions-to-their-network)). HTTP `401` · thrown by `@better-iam/auth` , `@better-iam/server` Example messages: * This session can only be used from the network it was signed in from ### UNAUTHENTICATED [#unauthenticated] The request has no valid credential, or its credential has expired or been revoked. It covers a missing or malformed session cookie or bearer token, an expired or idle session, a revoked session or API key, a disabled or expired identity, a role session or session token whose source was revoked, and an impersonation session whose administrator's session ended. **How to fix:** send the person to sign in again; the browser client calls its `onUnauthenticated` hook for this code, and framework integrations treat the request as signed out ([sessions](/docs/guides/authentication/sessions)). HTTP `401` · thrown by `@better-iam/auth` , `@better-iam/nestjs` , `@better-iam/server` Example messages: * Original session is no longer valid * Invalid authentication * Impersonation has ended * Invalid credentials * Invalid or missing credentials * Invalid or expired credentials ## 403 Forbidden [#403-forbidden] The caller is known, but this action is not allowed for them: no role or policy grants it, a deny or boundary blocks it, or a tenant policy (MFA, network, sign-in method) refuses the request. Retrying will not help until access or context changes. | Code | Meaning | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | [`ACCESS_DENIED`](#access_denied) | The caller is signed in but is not allowed to perform this action. | | [`CATALOG_LOCKED`](#catalog_locked) | Organizations cannot register their own resource types or actions on this deployment. | | [`CSRF`](#csrf) | A protocol endpoint that acts on a signed-in session was called without a trusted `Origin` or the `X-Better-IAM` header. | | [`CSRF_REJECTED`](#csrf_rejected) | The request did not carry the headers that prove it came from your application rather than another site. | | [`DELEGATION_INACTIVE`](#delegation_inactive) | The delegation can no longer be used to open delegated sessions. | | [`DELEGATION_NOT_ALLOWED`](#delegation_not_allowed) | The AI agent does not accept delegation. | | [`EMAIL_UNVERIFIED`](#email_unverified) | The account's email address is not verified, and this deployment requires verification before sign-in. | | [`FEATURE_DISABLED`](#feature_disabled) | The feature this call needs is turned off or not configured on this deployment or for this organization. | | [`FORBIDDEN`](#forbidden) | Self-registration was attempted in the root tenant, where only administrators create accounts. | | [`GRANT_AUTHORITY_REQUIRED`](#grant_authority_required) | The caller has permission for the action but holds no active grant authority to issue access under. | | [`HOST_MISMATCH`](#host_mismatch) | The request arrived on one organization's sign-in address but names or authenticates as another organization. | | [`IMPERSONATION_RESTRICTED`](#impersonation_restricted) | The operation is not available while an administrator is impersonating a member. | | [`INSUFFICIENT_SCOPE`](#insufficient_scope) | The OAuth access token is valid but lacks a scope the resource requires. | | [`INVALID_LINK`](#invalid_link) | The identities cannot be linked, or the link or linking proof is no longer valid. | | [`INVALID_SESSION`](#invalid_session) | OAuth consent was attempted with a credential that is not a person's signed-in session. | | [`INVALID_TENANT_TREE`](#invalid_tenant_tree) | The organization's chain of parent tenants contains a cycle, so authentication refuses it. | | [`IP_BLOCKED`](#ip_blocked) | Requests from this network are blocked for the organization or for the whole platform. | | [`IP_NOT_ALLOWED`](#ip_not_allowed) | The organization only allows sign-in from certain networks, and this address is not one of them. | | [`METHOD_NOT_ALLOWED`](#method_not_allowed) | The organization does not permit this sign-in method, or the HTTP method is not supported on this route. | | [`MFA_NOT_ENROLLED`](#mfa_not_enrolled) | The operation needs a second factor, but the person has not enrolled one. | | [`MFA_REQUIRED`](#mfa_required) | The session has not completed multi-factor authentication, and this action or organization requires it. | | [`OAUTH_TENANT`](#oauth_tenant) | The person's Microsoft directory is not allowed to sign in through this connection. | | [`PASSWORD_EXPIRED`](#password_expired) | The password is correct but older than the organization's maximum password age. | | [`PROTECTED_OPERATION`](#protected_operation) | An OAuth client was about to be stored outside the authenticated registration path. | | [`PROTECTED_RESOURCE`](#protected_resource) | The target is a protected owner role or policy, or belongs to a higher grant authority, and cannot be changed this way. | | [`RECENT_AUTH_REQUIRED`](#recent_auth_required) | The operation is sensitive, and the session was not authenticated recently enough. | | [`RESOURCE_MISMATCH`](#resource_mismatch) | Your `resolveResource` callback returned nothing, or a resource that does not match the one being authorized. | | [`SELF_REVIEW`](#self_review) | A reviewer tried to certify their own access in an access review. | | [`TENANT_INACTIVE`](#tenant_inactive) | The organization, or one of its parent tenants, is suspended, pending, or deleted. | | [`TENANT_MISMATCH`](#tenant_mismatch) | The signed-in person belongs to a different organization than the OAuth client asking for consent. | | [`TENANT_UNAVAILABLE`](#tenant_unavailable) | The organization is not active, so nobody can sign in to it. | | [`UNTRUSTED_ORIGIN`](#untrusted_origin) | The request came from a browser origin that the deployment does not trust. | | [`WEB_IDENTITY_REJECTED`](#web_identity_rejected) | The external token was not accepted by `sts.assumeRoleWithWebIdentity`, for a reason the response deliberately does not name. | ### ACCESS\_DENIED [#access_denied] The caller is signed in but is not allowed to perform this action. Every API operation checks an `iam:*` permission (for example `iam:groups:update`) and, when no role or policy grants it or a deny or boundary blocks it, records a `deny` audit event and fails with this code; `iam.require` and the framework guards throw it for your own actions too. It also covers specific refusals: approving your own access request, granting a role beyond your grant authority, changing a binding issued under a higher authority, cancelling someone else's request, or acting from a session that is not an ordinary session of the tenant. **How to fix:** ask an administrator for a role that grants the action. To see why a decision was made, use [`policies.simulate`](/docs/reference/api/policies#simulate) or [`accessPaths.find`](/docs/reference/api/access-paths#find). HTTP `400` , `403` · thrown by `@better-iam/cli` , `@better-iam/nestjs` , `@better-iam/server` Example messages: * Not allowed$\{decision.reason ? * Access denied * Access paths are for an ordinary session of the tenant * A request cannot be approved by its requester * Cannot grant role … * Only the requester can cancel a request ### CATALOG\_LOCKED [#catalog_locked] Organizations cannot register their own resource types or actions on this deployment. `resourceTypes.register` and `actions.register` need `permissions.mode: 'tenant-defined'` in the server options; in catalog mode only your configuration defines the catalog. **How to fix:** declare the types and actions under `permissions` in your configuration, or switch to tenant-defined mode if organizations should define their own ([catalog](/docs/guides/authorization/catalog)). HTTP `403` · thrown by `@better-iam/server` Example messages: * Tenant-defined resource types are disabled * Tenant-defined actions are disabled ### CSRF [#csrf] A protocol endpoint that acts on a signed-in session was called without a trusted `Origin` or the `X-Better-IAM` header. The OAuth and SAML sign-in handlers start account linking only for a `POST` with an `Origin` from `trustedOrigins`, the `X-Better-IAM: 1` header, and a JSON content type, and the OAuth provider's `completeInteraction` requires a trusted `Origin` on its consent `POST`. This stops other sites from starting these flows with the person's cookies. **How to fix:** send the request with `fetch` from your own origin (the browser sets `Origin`), include the header, and list the origin in `trustedOrigins` ([CSRF and Origin checks](/docs/guides/authentication/http#csrf-and-origin-checks)). HTTP `403` · thrown by `@better-iam/oauth` , `@better-iam/saml` Example messages: * Linking requires a trusted Origin and X-Better-IAM header. * A trusted Origin is required. ### CSRF\_REJECTED [#csrf_rejected] The request did not carry the headers that prove it came from your application rather than another site. API calls must be `POST` requests with `Content-Type: application/json` and `X-Better-IAM: 1`, and a request that carries cookies must also send an `Origin` header. The SCIM administration handler additionally rejects cross-origin requests, and the Next.js, NestJS, and middleware integrations apply the same cookie rule to your own routes. **How to fix:** call the API through the browser client, which sets both headers, or add them yourself ([CSRF and Origin checks](/docs/guides/authentication/http#csrf-and-origin-checks)). HTTP `403` · thrown by `@better-iam/next` , `@better-iam/scim` , `@better-iam/server` Example messages: * Cookie requests require Origin * JSON requests require X-Better-IAM: 1. * Cross-origin requests are rejected. * JSON and X-Better-IAM header required ### DELEGATION\_INACTIVE [#delegation_inactive] The delegation can no longer be used to open delegated sessions. `delegations.assume` refuses a delegation that was denied or revoked, a request that lapsed undecided, a delegation past its end, and one whose person is no longer an active, unexpired member of the tenant. **How to fix:** have the person grant a new delegation, or ask for one with `delegations.request`; [`delegations.get`](/docs/reference/api/delegations#get) shows the `status` and whether it `expired`. HTTP `403` · thrown by `@better-iam/server` Example messages: * This delegation is no longer active * A delegation above this one has ended * The person behind this delegation is not active ### DELEGATION\_NOT\_ALLOWED [#delegation_not_allowed] The AI agent does not accept delegation. Its profile sets `delegable: false`, so `delegations.grant`, `delegations.request`, `delegations.approve`, and `delegations.assume` refuse it, and its existing delegated sessions are refused (as `UNAUTHENTICATED`) until delegation is turned back on. **How to fix:** an administrator sets `delegable: true` with [`agents.update`](/docs/reference/api/agents#update). It also answers delegation limits that are not the agent's switch: a hand-off the person did not allow ([`delegations.handoff`](/docs/reference/api/delegations#handoff)), and a delegation token for an audience outside the `tokenAudiences` of an agent in the chain, or for a scope some limit on the session does not allow outright ([`delegations.issueToken`](/docs/reference/api/delegations#issuetoken)). Name an allowed audience or narrower `scopes`, or have an administrator add the audience to the agents' profiles. HTTP `403` · thrown by `@better-iam/server` Example messages: * This agent does not accept delegation * The person did not allow this delegation to be handed on * An agent cannot receive a hand-off from its own delegation chain * The person did not allow hand-offs to this agent * Agent … may not present delegations to … ### EMAIL\_UNVERIFIED [#email_unverified] The account's email address is not verified, and this deployment requires verification before sign-in. With `authentication.requireEmailVerification` (on by default when self-registration is enabled), sign-in, session issue, and every later use of a session refuse unverified identities. **How to fix:** have the person open the verification link from their email (`auth.verifyEmail`), or send a new one with `auth.requestEmailVerification` ([email verification](/docs/guides/authentication/recovery#email-verification)). HTTP `403` · thrown by `@better-iam/auth` Example messages: * Verify your email before signing in ### FEATURE\_DISABLED [#feature_disabled] The feature this call needs is turned off or not configured on this deployment or for this organization. Examples: self-registration without `signUpEnabled`, password sign-in or recovery with `emailPassword: false` or no `sendEmail`, a passwordless channel or passkeys that are not configured, passkey sign-in for an account without a passkey, emailed MFA codes the sign-in does not offer, a delivery kind without its callback, impersonation in a tenant whose policy does not set `allowImpersonation`, session JWTs without `sts.jwt`, custom hostnames without `hosts.customHostnames`, passkeys on a custom hostname outside the passkey domain (the RP ID), and the `inference` API group and `iam.inference` without the `inference` option. **How to fix:** enable the option in your [configuration](/docs/operations/deployment/configuration) or the tenant's [authentication policy](/docs/guides/authentication/tenant-policy), or hide the feature in your UI while it is off. HTTP `400` , `403` · thrown by `@better-iam/auth` , `@better-iam/server` Example messages: * … delivery is not configured * Delivery callback unavailable * Email delivery is not configured * Password recovery is unavailable * Password authentication is disabled * Impersonation is not enabled for this organization ### FORBIDDEN [#forbidden] Self-registration was attempted in the root tenant, where only administrators create accounts. `auth.signUp` refuses the platform's root tenant, because anyone who registered there would join the tenant that operates the whole platform. **How to fix:** sign people up in an organization tenant, and create root identities with `bootstrap`, `recover-root`, or an administrator. HTTP `403` · thrown by `@better-iam/auth` Example messages: * Root identity provisioning requires an administrator ### GRANT\_AUTHORITY\_REQUIRED [#grant_authority_required] The caller has permission for the action but holds no active grant authority to issue access under. Operations that grant access or create grantable records (bindings, roles, policies, group memberships, invitations, organizations, API keys) record the [grant authority](/docs/guides/authorization/roles#grant-authorities) they were issued under. A non-root administrator needs one that is not revoked and whose delegation chain is intact. **How to fix:** ask a root administrator, or someone holding a broader authority, to delegate one with [`authorities.create`](/docs/reference/api/authorities#create). HTTP `403` · thrown by `@better-iam/server` Example messages: * An active delegated grant authority is required ### HOST\_MISMATCH [#host_mismatch] The request arrived on one organization's sign-in address but names or authenticates as another organization. With organization addresses (`hosts`), a request on `acme.signin.example.com` or Acme's custom hostname is pinned to Acme: a sign-in call naming another `tenantId`, a session or API key of another organization, and a page on one organization's address calling the API on another's are all refused. Root administrators sign in at the deployment's own address, not an organization's. **How to fix:** send the person to their own organization's address (its `signInUrl` from [`tenants.lookup`](/docs/reference/api/tenants#lookup)), or leave `tenantId` out and let the address decide ([sign-in addresses](/docs/operations/deployment/hosts-and-regions)). HTTP `403` · thrown by `@better-iam/auth` , `@better-iam/server` Example messages: * This address belongs to another organization * This page and this address belong to different organizations ### IMPERSONATION\_RESTRICTED [#impersonation_restricted] The operation is not available while an administrator is impersonating a member. An impersonation ("view as") session never counts as recently authenticated, so every operation that needs [recent authentication](/docs/guides/authentication/sessions#recent-authentication) refuses it. Decisions made in the member's name are refused too: approving requests or activations, reviewing access, accepting agreements, granting OAuth consent, requesting packages, assuming roles, change previews, and starting another impersonation. **How to fix:** end the impersonation and act from your own session ([impersonation](/docs/guides/authentication/impersonation)). HTTP `403` · thrown by `@better-iam/auth` , `@better-iam/oauth` , `@better-iam/server` Example messages: * An impersonated session cannot be re-authenticated * Only a person acting through their own session can impersonate * This operation is unavailable while impersonating a member * Consent cannot be granted while impersonating a member. * Access paths are not available while impersonating * Agreements cannot be accepted while impersonating ### INSUFFICIENT\_SCOPE [#insufficient_scope] The OAuth access token is valid but lacks a scope the resource requires. Resource servers built with `createAccessTokenVerifier` or `createResourceGuard` check the scopes they were configured with and answer with a `WWW-Authenticate` challenge naming `insufficient_scope`; the message lists the missing scopes. **How to fix:** have the client request the missing scopes in a new authorization, and make sure it is allowed to ([resource servers](/docs/federation/oauth-resource-servers)). HTTP `403` · thrown by `@better-iam/oauth` Example messages: * Missing scope: …. ### INVALID\_LINK [#invalid_link] The identities cannot be linked, or the link or linking proof is no longer valid. [`links.create`](/docs/reference/api/links#create) links only ordinary user identities of two different tenants, and linked onboarding needs an ordinary user session. `links.switch` refuses a link that no longer holds, and a federated linking callback refuses a proof whose session or identity changed. **How to fix:** link from two signed-in user sessions in different tenants, and start a linking flow again if its session ended. HTTP `400` , `403` · thrown by `@better-iam/server` Example messages: * Invalid verified account-linking proof * Linked onboarding requires an ordinary user session * Only separate ordinary tenant identities can link * Only ordinary user identities can link * Invalid identity link ### INVALID\_SESSION [#invalid_session] OAuth consent was attempted with a credential that is not a person's signed-in session. The OAuth provider's `completeInteraction` requires a user session, so API keys and temporary credentials cannot grant consent on anyone's behalf. **How to fix:** sign the person in to the client's organization and pass their session cookie or token ([OAuth provider](/docs/federation/oauth-provider)). HTTP `403` · thrown by `@better-iam/oauth` Example messages: * A user session is required. ### INVALID\_TENANT\_TREE [#invalid_tenant_tree] The organization's chain of parent tenants contains a cycle, so authentication refuses it. Before sign-in and on every session use, the authentication service walks the tenant and its ancestors; a tenant that is its own ancestor can only come from data changed outside Better IAM. The Next.js integration treats it as a signed-out request. **How to fix:** repair the tenants' `parentId` values (for example from a backup), then sign in again. HTTP `403` · thrown by `@better-iam/auth` Example messages: * Invalid tenant hierarchy ### IP\_BLOCKED [#ip_blocked] Requests from this network are blocked for the organization or for the whole platform. Administrators block networks with `security.blockNetwork`. A blocked address is refused before any credential is checked (so no rate-limit counter moves), and existing sessions from it stop working at their next use. **How to fix:** connect from another network, or ask an administrator to lift the block with `security.unblockNetwork` or wait for it to expire ([network blocks](/docs/guides/authentication/tenant-policy#network-blocks)). HTTP `403` · thrown by `@better-iam/auth` Example messages: * Sign-in from this network is blocked ### IP\_NOT\_ALLOWED [#ip_not_allowed] The organization only allows sign-in from certain networks, and this address is not one of them. A tenant's `authPolicy.allowedIpRanges` is checked when a session is issued and on every later use, including role sessions derived from it; requests without a known client address are not judged. **How to fix:** connect from an allowed network (for example the company VPN), or ask an administrator to add the range ([IP allowlist](/docs/guides/authentication/tenant-policy#ip-allowlist)). HTTP `403` · thrown by `@better-iam/auth` Example messages: * Sign-in from this network is not permitted for this organization ### METHOD\_NOT\_ALLOWED [#method_not_allowed] The organization does not permit this sign-in method, or the HTTP method is not supported on this route. With status 403 it comes from a tenant's `authPolicy.allowedMethods`, for example a tenant that allows only federated sign-in refusing a password. With status 405 an API, SCIM, or webhook route was called with an unsupported HTTP method; API calls are `POST`. **How to fix:** offer the person a method the organization allows ([restricting sign-in methods](/docs/guides/authentication/tenant-policy#restricting-sign-in-methods)), or send the request as `POST`. HTTP `403` , `405` · thrown by `@better-iam/auth` , `@better-iam/scim` , `@better-iam/server` Example messages: * This sign-in method is not permitted for this organization * Use POST. * Use POST * Method is not supported ### MFA\_NOT\_ENROLLED [#mfa_not_enrolled] The operation needs a second factor, but the person has not enrolled one. `auth.verifyMfa` refuses a code when the person has no authenticator app and no emailed code was requested for this sign-in. Regenerating recovery codes, and an MFA step-up on an existing credential (such as a temporary credential requested with an MFA code), need an enrolled authenticator app. **How to fix:** enroll a factor first with `auth.beginMfa` and `auth.confirmMfa` ([MFA](/docs/guides/authentication/mfa)). HTTP `403` · thrown by `@better-iam/auth` , `@better-iam/server` Example messages: * MFA is not enrolled * Only a signed-in person can verify MFA ### MFA\_REQUIRED [#mfa_required] The session has not completed multi-factor authentication, and this action or organization requires it. It is raised when the deployment or tenant requires MFA and a session without it is issued or used (for example after the organization turned the requirement on), when activating an eligible role whose rules require an MFA-verified session, when disabling MFA or regenerating recovery codes, when impersonating a member who needs MFA, and by framework step-up guards. **How to fix:** send the person through the second-factor step and retry; framework integrations treat it as a signed-out request ([step-up](/docs/guides/authentication/mfa#step-up)). HTTP `403` · thrown by `@better-iam/auth` , `@better-iam/server` Example messages: * Multi-factor authentication is required * Complete multi-factor authentication before impersonating a member who requires it * Use an existing MFA factor before changing enrollment * MFA is required by this tenant * Activation requires an MFA-verified session ### OAUTH\_TENANT [#oauth_tenant] The person's Microsoft directory is not allowed to sign in through this connection. A Microsoft connection with `allowedMicrosoftTenants` accepts ID tokens only from those directory ids. **How to fix:** add the directory id to the connection's list if it should be accepted, or sign in with an account from an allowed directory. HTTP `403` · thrown by `@better-iam/oauth` Example messages: * This Microsoft directory may not sign in here. ### PASSWORD\_EXPIRED [#password_expired] The password is correct but older than the organization's maximum password age. A tenant's `authPolicy.passwordMaxAgeDays` makes sign-in refuse a password that was not changed within that many days. **How to fix:** send the person through password reset (`auth.requestPasswordReset`, then `auth.resetPassword`) ([password reset](/docs/guides/authentication/recovery#password-reset)). HTTP `403` · thrown by `@better-iam/auth` Example messages: * Your password has expired; reset it to continue ### PROTECTED\_OPERATION [#protected_operation] An OAuth client was about to be stored outside the authenticated registration path. Better IAM's OAuth provider stores clients only through `issuer.registerClient()`, which checks the caller's permission, or through a dynamic registration admitted for the same tenant; any other attempt to write a client is refused. **How to fix:** register clients with `registerClient` ([OAuth provider](/docs/federation/oauth-provider)). HTTP `403` · thrown by `@better-iam/oauth` Example messages: * Use authenticated registerClient(). ### PROTECTED\_RESOURCE [#protected_resource] The target is a protected owner role or policy, or belongs to a higher grant authority, and cannot be changed this way. Owner roles cannot be requested, approved, bound, packaged, inherited, or assumed through trust, and protected roles and the owner policy cannot be edited or deleted; ownership moves only through [`identities.setOwner`](/docs/reference/api/identities#setowner). Roles and policies created under a superior authority can be edited only by that authority. **How to fix:** use owner transfer for ownership, and ask the administrator whose authority created the role or policy to make the change. HTTP `400` , `403` · thrown by `@better-iam/server` Example messages: * Owner roles cannot be requested * Owner roles cannot be granted this way * Use owner transfer for protected roles * Use owner transfer * Protected roles cannot be packaged * Owner policy is protected ### RECENT\_AUTH\_REQUIRED [#recent_auth_required] The operation is sensitive, and the session was not authenticated recently enough. Password, email, factor, session, credential, ownership, and policy changes, impersonation, and similar operations need a session established within `authentication.recentAuthenticationMs` (five minutes by default). Temporary credentials such as assumed-role sessions and session tokens never qualify, and OAuth or SAML account linking needs a sign-in within the last five minutes. **How to fix:** reauthenticate (for example `auth.reauthenticate` with the password, completing MFA if asked) and retry ([recent authentication](/docs/guides/authentication/sessions#recent-authentication)). HTTP `403` · thrown by `@better-iam/auth` , `@better-iam/oauth` , `@better-iam/saml` Example messages: * Temporary credentials cannot perform this operation; use a signed-in session * Reauthenticate to perform this operation * Linking requires recent authentication to a non-root identity in this tenant. ### RESOURCE\_MISMATCH [#resource_mismatch] Your `resolveResource` callback returned nothing, or a resource that does not match the one being authorized. For application resource types, Better IAM asks your callback for the resource and requires the returned `tenantId`, `type`, and `id` to equal the request's, so a faulty lookup can never authorize one tenant's request with another tenant's record. The request is refused instead. **How to fix:** return exactly the requested resource from `resolveResource` ([resources and catalog](/docs/guides/concepts/resources-and-catalog)). HTTP `403` · thrown by `@better-iam/server` Example messages: * Resource ownership mismatch ### SELF\_REVIEW [#self_review] A reviewer tried to certify their own access in an access review. Certification campaigns refuse a reviewer's decision on an item that grants the reviewer access, directly or through one of their groups. **How to fix:** leave the item to another reviewer of the campaign ([certifications](/docs/guides/governance/certifications)). HTTP `403` · thrown by `@better-iam/server` Example messages: * Reviewers cannot certify their own access; ask another reviewer ### TENANT\_INACTIVE [#tenant_inactive] The organization, or one of its parent tenants, is suspended, pending, or deleted. Every API call re-checks the caller's tenant ancestry, so suspending an organization stops its sessions at their next request. Creating identities or invitations, opening access requests, moving a tenant under an inactive parent, and the OAuth, SAML, SCIM, and shared-signals endpoints refuse inactive tenants too; framework integrations treat it as a signed-out request. **How to fix:** reactivate the organization and its parents with `tenants.setStatus`, or finish onboarding a pending one. HTTP `400` , `403` · thrown by `@better-iam/oauth` , `@better-iam/saml` , `@better-iam/scim` , `@better-iam/server` Example messages: * Tenant is unavailable. * Tenant unavailable. * Tenant must be active * The organization is not active * New parent ancestry must be active * Parent must be active ### TENANT\_MISMATCH [#tenant_mismatch] The signed-in person belongs to a different organization than the OAuth client asking for consent. The OAuth provider's `completeInteraction` accepts consent only from a user session in the client's tenant. **How to fix:** sign the person in to the client's organization before the consent step ([OAuth provider](/docs/federation/oauth-provider)). HTTP `403` · thrown by `@better-iam/oauth` Example messages: * Sign in to the client tenant before continuing. ### TENANT\_UNAVAILABLE [#tenant_unavailable] The organization is not active, so nobody can sign in to it. Authentication checks the tenant and every ancestor before sign-in, sign-up, and recovery and on every session use; a missing, suspended, pending, or deleted tenant anywhere in the chain is refused. Framework integrations treat it as a signed-out request. **How to fix:** check that the person chose the right organization, and ask the platform operator to reactivate it if it was suspended. HTTP `403` · thrown by `@better-iam/auth` Example messages: * Tenant is not active ### UNTRUSTED\_ORIGIN [#untrusted_origin] The request came from a browser origin that the deployment does not trust. A request with an `Origin` header must name an exact origin from `trustedOrigins` (the origin of `baseURL` is always included); the Next.js and middleware integrations apply the same rule to cookie requests on your routes. **How to fix:** add the origin (scheme, host, and port) to `trustedOrigins` in your [configuration](/docs/operations/deployment/configuration). HTTP `403` · thrown by `@better-iam/next` , `@better-iam/server` Example messages: * Origin is not trusted ### WEB\_IDENTITY\_REJECTED [#web_identity_rejected] The external token was not accepted by `sts.assumeRoleWithWebIdentity`, for a reason the response deliberately does not name. The public exchange answers every refusal that depends on stored state or on the token with this same 403 body: an unknown or revoked trust, a disabled provider, a bad signature, issuer, audience, type, or lifetime, a token that is expired or too old, unmet trust conditions, a missing source identity claim, a token already redeemed, an inactive service account, a revoked authority, or an inactive tenant. Keeping them identical stops callers from discovering which trusts, roles, and providers exist. **How to fix:** an administrator runs [`trust.evaluateWebIdentity`](/docs/reference/api/trust#evaluatewebidentity) with the same token, which reports the reason and each failing condition, and checks the audit log for `role:assumed-with-web-identity` denials, whose metadata carries the reason. HTTP `403` · thrown by `@better-iam/server` Example messages: * The web identity token was not accepted ## 404 Not found [#404-not-found] The record does not exist in this tenant. Better IAM also answers 404 for records in other tenants, so responses never reveal what exists elsewhere. | Code | Meaning | | --------------------------------------- | --------------------------------------------------------------------------------------- | | [`NOT_FOUND`](#not_found) | The record does not exist in this tenant. | | [`OAUTH_CLIENT`](#oauth_client) | The OAuth client is unknown, revoked, or belongs to an organization that is not active. | | [`TENANT_NOT_FOUND`](#tenant_not_found) | An identity was about to be created in a tenant that does not exist. | ### NOT\_FOUND [#not_found] The record does not exist in this tenant. Better IAM also answers 404 for records that belong to another tenant, so responses never reveal what exists elsewhere. Deleted identities, unregistered managed resources, unknown routes, and the public `tenants.lookup` and `domains.discover` calls for unknown or inactive organizations answer the same way. **How to fix:** check the id and the `tenantId` you passed, and refresh lists that may be stale. HTTP `404` · thrown by `@better-iam/auth` , `@better-iam/cli` , `@better-iam/core` , `@better-iam/oauth` , `@better-iam/projects` , `@better-iam/saml` , `@better-iam/scim` , `@better-iam/server` Example messages: * Passkey not found * Device not found * Session not found * Endpoint not found * Cannot update a missing record * OAuth connection not found. ### OAUTH\_CLIENT [#oauth_client] The OAuth client is unknown, revoked, or belongs to an organization that is not active. When Better IAM acts as an OAuth provider, it refuses authorization, consent, and client changes for such clients. **How to fix:** check the `client_id`, register a new client if it was revoked, or reactivate the organization ([OAuth provider](/docs/federation/oauth-provider)). HTTP `404` · thrown by `@better-iam/oauth` Example messages: * Client is unavailable. ### TENANT\_NOT\_FOUND [#tenant_not_found] An identity was about to be created in a tenant that does not exist. It is a safety check inside the low-level identity creation that sign-up, invitations, federation, and root recovery share. Those flows check the tenant first and normally answer `TENANT_UNAVAILABLE` or `NOT_FOUND` instead. **How to fix:** check the `tenantId` you passed. HTTP `404` · thrown by `@better-iam/auth` Example messages: * Tenant does not exist ## 409 Conflict [#409-conflict] The request conflicts with the current state: a duplicate name or email, a version that changed since it was read, or a one-time token that was already used. Reload the current state and decide again. | Code | Meaning | | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`ACCOUNT_LINK_CONFLICT`](#account_link_conflict) | The external account you are linking is already linked to a different account in this tenant. | | [`ACCOUNT_LINK_REQUIRED`](#account_link_required) | A first sign-in through an external provider used an email that already belongs to an account in this tenant, so the accounts must be linked explicitly. | | [`ALREADY_INITIALIZED`](#already_initialized) | Bootstrap was run on a database that already has a root tenant. | | [`ARCHIVE_CONFLICT`](#archive_conflict) | The audit archive already holds a file for this batch of events with different contents. | | [`BILLING_PERIOD_CLOSED`](#billing_period_closed) | The month has already been invoiced for this billing account, so its usage and prices can no longer change. | | [`CONFLICT`](#conflict) | The request conflicts with a record that already exists or with the record's current state. | | [`DATABASE_IN_USE`](#database_in_use) | Code running inside a transaction tried to use the same SQLite or libSQL database through a second adapter instance. | | [`DELEGATION_EXISTS`](#delegation_exists) | An AI agent and a person already have a pending request or an active delegation between them. | | [`DELEGATION_PENDING`](#delegation_pending) | The person has not approved this delegation yet. | | [`DOMAIN_TAKEN`](#domain_taken) | Another organization has already verified this email domain. | | [`FEATURE_LOCKED`](#feature_locked) | A tenant tried to choose its own value for a feature flag (`features.setOverride`) that the flag's owners decide for it. | | [`HOSTNAME_TAKEN`](#hostname_taken) | Another organization has already verified this custom hostname. | | [`IDENTITY_EXISTS`](#identity_exists) | An identity with this email address already exists in the tenant. | | [`INVALID_IDENTITY`](#invalid_identity) | The identity involved is not in a state that allows this operation. | | [`INVALID_TRANSITION`](#invalid_transition) | The record is not in a state that allows this step. | | [`INVARIANT_VIOLATION`](#invariant_violation) | The change would break an enforced access invariant, so it was rolled back. | | [`LAST_AUTHENTICATOR`](#last_authenticator) | Deleting this passkey would leave the account with no way to sign in. | | [`LAST_OWNER`](#last_owner) | The operation would leave the organization without an active owner. | | [`LAST_ROOT_ADMIN`](#last_root_admin) | The operation would leave the platform without an active root administrator. | | [`LIMIT_EXCEEDED`](#limit_exceeded) | The tenant has reached a limit on how many of these records it may have. | | [`METER_ARCHIVED`](#meter_archived) | The billing meter is archived and accepts no new usage. | | [`MFA_ALREADY_ENABLED`](#mfa_already_enabled) | The person already has an authenticator app enrolled. | | [`PASSKEY_EXISTS`](#passkey_exists) | This passkey is already registered. | | [`PHONE_EXISTS`](#phone_exists) | Another person in this organization has already verified this phone number. | | [`RESOURCE_IN_USE`](#resource_in_use) | The record is still referenced by other records, so it cannot be deleted or changed this way. | | [`SAME_DATABASE`](#same_database) | The source and target of a store copy are the same database. | | [`SLUG_TAKEN`](#slug_taken) | Another organization already uses this slug. | | [`SOD_CONFLICT`](#sod_conflict) | The change would give one person a combination of roles that a separation-of-duties rule forbids. | | [`STORE_NOT_EMPTY`](#store_not_empty) | The target database of an import or copy already holds records. | | [`TEAM_MANAGED`](#team_managed) | The group belongs to a team, and its members come from the team. | | [`TRANSACTION_ABORTED`](#transaction_aborted) | A database operation failed inside a transaction, so the whole transaction was rolled back. | | [`VERSION_CONFLICT`](#version_conflict) | The record changed since you read it, so your change or acceptance was refused. | ### ACCOUNT\_LINK\_CONFLICT [#account_link_conflict] The external account you are linking is already linked to a different account in this tenant. It happens at the callback of an OAuth or SAML linking flow when the provider identity (provider, issuer, and subject) already belongs to another identity. A provider identity links to one account at a time, and links never move silently. **How to fix:** sign in with that provider to reach the account it already belongs to ([link an existing account](/docs/federation/oauth-sign-in#link-an-existing-account)). HTTP `409` · thrown by `@better-iam/server` Example messages: * External identity is already linked to another account ### ACCOUNT\_LINK\_REQUIRED [#account_link_required] A first sign-in through an external provider used an email that already belongs to an account in this tenant, so the accounts must be linked explicitly. Better IAM never merges accounts by email, because anyone who could set that address at some provider could otherwise take over the account. **How to fix:** sign in to the existing account (password, passkey, or another linked provider), reauthenticate, and start the provider's linking flow from that session; later sign-ins with the provider then reach that account ([link an existing account](/docs/federation/oauth-sign-in#link-an-existing-account)). HTTP `409` · thrown by `@better-iam/server` Example messages: * Authenticate the existing account before linking this provider ### ALREADY\_INITIALIZED [#already_initialized] Bootstrap was run on a database that already has a root tenant. [`bootstrap`](/docs/reference/api#bootstrap) and the `better-iam bootstrap` command create the root tenant and the first root administrator exactly once. **How to fix:** nothing needs creating. Sign in with an existing root administrator, or run [`recover-root`](/docs/reference/cli#recover-root) if you lost access to all of them ([recovering root access](/docs/guides/authentication/recovery#recovering-root-access)). HTTP `409` · thrown by `@better-iam/server` Example messages: * Root already exists ### ARCHIVE\_CONFLICT [#archive_conflict] The audit archive already holds a file for this batch of events with different contents. The JSONL archive sink writes each batch once, named by tenant and sequence range, and never replaces an existing file. A different batch under the same name means two deployments share one archive directory, or the database's audit chain diverged from what was archived earlier (for example after restoring an old backup). `archiveAudit` reports it per tenant in its `failed` list, and `audit-archive` then fails with `AUDIT_ARCHIVE_FAILED`. **How to fix:** keep the existing file as evidence, give each deployment its own archive directory, and investigate why the chain changed ([continuous audit archiving](/docs/operations/jobs#continuous-audit-archiving)). HTTP `409` · thrown by `@better-iam/server` Example messages: * …/… already holds different events ### BILLING\_PERIOD\_CLOSED [#billing_period_closed] The month has already been invoiced for this billing account, so its usage and prices can no longer change. `billing.record` (and `iam.billing.record`) refuse usage whose `occurredAt` falls in a month with a statement for the account, and `billing.setPrice` refuses an `effectiveFrom` that reaches back into an invoiced month. **How to fix:** record late usage in the current month, or have a root administrator void the statement (`billing.voidStatement`), record the correction, and close the month again ([billing](/docs/reference/api/billing)). HTTP `409` · thrown by `@better-iam/server` Example messages: * A period from effectiveFrom on has already been invoiced; invoiced periods keep their prices * … has already been invoiced; leave period out to bill the next invoice * … has already been invoiced for this billing account ### CONFLICT [#conflict] The request conflicts with a record that already exists or with the record's current state. Typical causes are a duplicate (an agreement, invariant, access package, resource type, or action with the same name, a resource or domain that is already registered or claimed, a binding or group membership that already exists, an identical pending request), acting on a certification campaign that is closed, and reusing an invitation that was consumed or revoked. The storage layer also raises it for any duplicate id or tenant-unique key and for an attempt to move a record to another tenant. **How to fix:** reload the current state and decide again; for a duplicate, use the existing record or choose another name. HTTP `409` · thrown by `@better-iam/core` , `@better-iam/oauth` , `@better-iam/projects` , `@better-iam/saml` , `@better-iam/scim` , `@better-iam/server` Example messages: * Record tenant cannot be changed * A record with this identifier or tenant key already exists * Client ID already exists; tenant binding is immutable. * A project with this name already exists * A SAML connection with this ID already exists. * A revoked connection cannot be rotated. ### DATABASE\_IN\_USE [#database_in_use] Code running inside a transaction tried to use the same SQLite or libSQL database through a second adapter instance. The adapters refuse it instead of waiting forever for a lock their own caller holds. Copying a database onto itself is the usual cause, which `copyStore` reports as `SAME_DATABASE`. **How to fix:** inside a transaction, read and write through the transaction's store, and share one adapter instance per database file. HTTP `409` · thrown by `@better-iam/adapter-libsql` , `@better-iam/adapter-sqlite` Example messages: * This call chain already holds this database through another adapter instance ### DELEGATION\_EXISTS [#delegation_exists] An AI agent and a person already have a pending request or an active delegation between them. One live delegation links an agent and a person at a time. `delegations.grant` refuses a new one while the agent's request waits for the person's decision or while the person already delegates to the agent, and `delegations.request` refuses a second request. **How to fix:** approve or deny the pending request, or revoke the existing delegation before granting a new one ([delegations](/docs/reference/api/delegations)). HTTP `409` · thrown by `@better-iam/server` Example messages: * A request or delegation between this agent and person already exists ### DELEGATION\_PENDING [#delegation_pending] The person has not approved this delegation yet. `delegations.assume` answers it for a request the person is still deciding; a request waits up to seven days. **How to fix:** poll [`delegations.get`](/docs/reference/api/delegations#get) until `status` becomes `active` (or `denied`), then call `assume` again. HTTP `409` · thrown by `@better-iam/server` Example messages: * The person has not approved this delegation yet ### DOMAIN\_TAKEN [#domain_taken] Another organization has already verified this email domain. A verified domain belongs to exactly one tenant, so `domains.add` and `domains.verify` refuse it once another tenant verified it first. **How to fix:** if the domain is yours, the other organization must release it with `domains.delete` before you can verify it. HTTP `409` · thrown by `@better-iam/server` Example messages: * Another organization verified this domain ### FEATURE\_LOCKED [#feature_locked] A tenant tried to choose its own value for a feature flag (`features.setOverride`) that the flag's owners decide for it. The flag does not allow tenants to choose (`tenantOverridable` is off), or a locked target set by the tenant that defines the flag covers this tenant or one of its ancestors. Withdrawing an earlier choice (`value: null`) is always allowed. **How to fix:** ask the flag's managers (root administrators for a platform flag) to allow overrides or to lift the lock. [`features.list`](/docs/reference/api/features#list) shows `overridable` and `locked` for each flag. HTTP `409` · thrown by `@better-iam/server` Example messages: * This flag does not let tenants choose their own value * A locked target decides this flag for the tenant ### HOSTNAME\_TAKEN [#hostname_taken] Another organization has already verified this custom hostname. A verified hostname belongs to exactly one organization, so `hostnames.add` and `hostnames.verify` refuse it once another organization verified it first. **How to fix:** if the hostname is yours, the other organization must release it with [`hostnames.delete`](/docs/reference/api/hostnames#delete) before you can verify it. HTTP `409` · thrown by `@better-iam/server` Example messages: * Another organization verified this hostname ### IDENTITY\_EXISTS [#identity_exists] An identity with this email address already exists in the tenant. Emails are unique within a tenant (another tenant may have its own identity with the same address), so sign-up, `identities.create`, `identities.createMany`, `identities.invite`, and email changes refuse a duplicate. **How to fix:** use the existing identity or another address; a person who already has an account should sign in or reset their password instead of signing up again. HTTP `409` · thrown by `@better-iam/auth` , `@better-iam/server` Example messages: * Email is already in use in this tenant * An identity with this email already exists in this tenant ### INVALID\_IDENTITY [#invalid_identity] The identity involved is not in a state that allows this operation. `accessRequests.approve` refuses a request whose requester is no longer active, and `credentials.create` issues API keys only to active, unexpired service accounts and to AI agents in good standing. `delegations.grant` and `delegations.approve` (409) refuse an agent that is not in good standing: suspended, expired, or with a sponsor who is no longer an active person. **How to fix:** re-enable the identity (or extend its expiry) first, or issue the key to a service account rather than a person. For an agent, check [`agents.standing`](/docs/reference/api/agents#standing), then resume it or name an active sponsor with `agents.update`. HTTP `400` , `409` · thrown by `@better-iam/server` Example messages: * Requester is not active * The agent is not in good standing * API keys require an active service account * API keys require an agent in good standing * Web-identity trusts require an active service account ### INVALID\_TRANSITION [#invalid_transition] The record is not in a state that allows this step. Examples: deciding or cancelling an access request, activation, or package request that is no longer pending; activating a binding that is not eligible or that nobody can approve; re-enabling an expired identity or service account before extending `expiresAt`; changing a deleted tenant or making a status change its lifecycle does not allow; pinging or redelivering through a paused webhook; and ending a rule-based package assignment by hand. **How to fix:** reload the record, check its current status, and take a step that status allows. HTTP `400` , `409` · thrown by `@better-iam/projects` , `@better-iam/server` Example messages: * Project is not active * Project is not archived * Request is … * Extend or clear expiresAt before resuming an expired agent * Deleted tenants cannot be updated * The credit is already revoked ### INVARIANT\_VIOLATION [#invariant_violation] The change would break an enforced access invariant, so it was rolled back. Access-changing operations (role, policy, binding, group, package, relationship, and configuration changes, among others) re-evaluate the tenant's invariants in `enforce` mode inside their transaction. They refuse a change that creates a new violation or makes an invariant impossible to evaluate (for example by deleting the group it names); the message names the invariant and the person affected. **How to fix:** preview the change with [`impact.preview`](/docs/reference/api/impact#preview), then adjust the change or the invariant ([access invariants](/docs/guides/governance/change-safety#access-invariants)). HTTP `409` · thrown by `@better-iam/server` Example messages: * Access invariant "…" could no longer be evaluated (…); change or delete the invariant first * Access invariant "…": … would … … on …/… ### LAST\_AUTHENTICATOR [#last_authenticator] Deleting this passkey would leave the account with no way to sign in. `auth.deletePasskey` refuses the last passkey when the account has no password and no verified email or phone that the deployment's passwordless sign-in could use. **How to fix:** add another sign-in method first (a password or a second passkey), then delete the old passkey. HTTP `409` · thrown by `@better-iam/auth` Example messages: * Configure another login method before removing the last passkey ### LAST\_OWNER [#last_owner] The operation would leave the organization without an active owner. Deleting, disabling, offboarding, or setting an expiry on the last active owner, or removing their owner flag with `identities.setOwner`, is refused. **How to fix:** make someone else an owner with [`identities.setOwner`](/docs/reference/api/identities#setowner) first, then retry. HTTP `409` · thrown by `@better-iam/server` Example messages: * Cannot remove the final active owner ### LAST\_ROOT\_ADMIN [#last_root_admin] The operation would leave the platform without an active root administrator. Deleting, disabling, or offboarding the last root administrator, or removing their root flag with `root.setAdministrator`, is refused so the platform always keeps a way in. **How to fix:** grant root to another person with [`root.setAdministrator`](/docs/reference/api/root#setadministrator) first. HTTP `409` · thrown by `@better-iam/server` Example messages: * Cannot remove the final root administrator ### LIMIT\_EXCEEDED [#limit_exceeded] The tenant has reached a limit on how many of these records it may have. Root administrators set plan limits per tenant with `tenants.setLimits` (members, service accounts, groups, roles, policies, registered resources, and webhooks), and every creation path checks them, including invitations, sign-up, federation, and SCIM. Fixed caps also apply: 50 webhooks, 50 agreements, and 100 invariants per tenant, and 5,000 bindings per certification campaign. **How to fix:** remove records you no longer need, ask the platform operator to raise the limit, or narrow the campaign by role or subject type; `tenants.usage` shows the current counts. HTTP `409` · thrown by `@better-iam/auth` , `@better-iam/server` Example messages: * This tenant has reached its member limit (…) * At most … agreements * A tenant can define at most … meters * A tenant can have at most … budgets * At most … plans * A campaign covers at most … bindings; narrow it by role or subject type ### METER\_ARCHIVED [#meter_archived] The billing meter is archived and accepts no new usage. Archiving (`billing.updateMeter` with `archived: true`) keeps a meter's history and prices but stops `billing.record` and `iam.billing.record` from adding to it. **How to fix:** record on the meter that replaced it, or restore the meter with `archived: false` ([billing](/docs/reference/api/billing)). HTTP `409` · thrown by `@better-iam/server` Example messages: * This meter no longer accepts usage ### MFA\_ALREADY\_ENABLED [#mfa_already_enabled] The person already has an authenticator app enrolled. `auth.beginMfa` starts a new authenticator enrollment only when none is active. **How to fix:** to replace the authenticator, disable MFA with `auth.disableMfa` where the tenant allows it, then enroll again ([managing factors](/docs/guides/authentication/mfa#managing-factors)). HTTP `409` · thrown by `@better-iam/auth` Example messages: * MFA is already enabled ### PASSKEY\_EXISTS [#passkey_exists] This passkey is already registered. A passkey credential can be registered once for this relying party, even across organizations. **How to fix:** sign in with the existing passkey, or create a new passkey on the device and register that one. HTTP `409` · thrown by `@better-iam/auth` Example messages: * Passkey is already registered ### PHONE\_EXISTS [#phone_exists] Another person in this organization has already verified this phone number. `auth.confirmPhoneVerification` keeps verified phone numbers unique within a tenant, so SMS codes and SMS sign-in reach exactly one account. **How to fix:** verify a different number. HTTP `409` · thrown by `@better-iam/auth` Example messages: * Phone is already verified by another identity ### RESOURCE\_IN\_USE [#resource_in_use] The record is still referenced by other records, so it cannot be deleted or changed this way. Examples: a role that other roles inherit or packages include, a policy still attached to a role, a group that packages grant or that approves requests, a resource type with registered resources, relationships, or child types, a resource with child resources, a package that is still assigned, and an action still used by policies and roles. **How to fix:** remove or repoint the references the message names, then retry. HTTP `400` , `409` · thrown by `@better-iam/server` Example messages: * This meter has recorded usage; archive it instead * Remove relationships before dropping a relation * Delete registered resources first * Delete relationships first * Other resource types use this type as their parent * Move or delete the departments below it first: … ### SAME\_DATABASE [#same_database] The source and target of a store copy are the same database. `store-copy` and `copyStore` would otherwise read and overwrite one database in the same run. **How to fix:** point `--target-config` at the configuration of a different, empty database ([snapshots](/docs/operations/storage#snapshots-and-moving-between-databases)). HTTP `409` · thrown by `@better-iam/core` Example messages: * The source and target are the same database ### SLUG\_TAKEN [#slug_taken] Another organization already uses this slug. Slugs are globally unique aliases that sign-in screens use to find an organization; `tenants.create`, `tenants.setSlug`, and `bootstrap` claim them. **How to fix:** choose a different slug. HTTP `409` · thrown by `@better-iam/server` Example messages: * This slug is already in use ### SOD\_CONFLICT [#sod_conflict] The change would give one person a combination of roles that a separation-of-duties rule forbids. Operations that grant roles (bindings, group membership, identity creation, access-request and package approvals, package assignments, and configuration apply) check the tenant's `prevent` rules inside their transaction and roll back when they create a new conflict. Conflicts that already existed never block unrelated work. **How to fix:** remove one of the conflicting roles from the person first, or grant a different role ([separation of duties](/docs/guides/authorization/separation-of-duties)). HTTP `409` · thrown by `@better-iam/server` Example messages: * Separation of duties (…): one person cannot hold … ### STORE\_NOT\_EMPTY [#store_not_empty] The target database of an import or copy already holds records. `store-import` and `store-copy` load a whole deployment in one transaction, and only into an empty, migrated database, so two deployments never mix. **How to fix:** point the target configuration at a new, empty database ([snapshots](/docs/operations/storage#snapshots-and-moving-between-databases)). HTTP `409` · thrown by `@better-iam/core` Example messages: * The target store already holds records (…); import into an empty, migrated database * The target store already holds records ### TEAM\_MANAGED [#team_managed] The group belongs to a team, and its members come from the team. Every team owns a backing group (`team:{slug}`) holding the members of the team and of the teams below it. Only the [`teams`](/docs/reference/api/teams) API changes who is in it: `groups.addMember`, `groups.updateMember`, `groups.removeMember`, and `groups.delete` refuse it, and access packages, invitations, and onboarding flows cannot name it. **How to fix:** add or remove people with `teams.addMember` / `teams.removeMember`, or delete the team with `teams.delete`. Binding roles to the backing group with `bindings.create` is how a team gets access, and is allowed. HTTP `409` · thrown by `@better-iam/server` Example messages: * This group belongs to a team; manage its members with the teams API ### TRANSACTION\_ABORTED [#transaction_aborted] A database operation failed inside a transaction, so the whole transaction was rolled back. Once a statement fails, the adapters mark the transaction as doomed: catching the error and continuing does not save it, and the transaction ends with this code instead of committing partial work. You meet it in plugins or custom code that swallow storage errors inside a transaction. **How to fix:** let storage errors propagate, or check for the condition before writing ([adapter contract](/docs/operations/extensions#rules-every-adapter-must-keep)). HTTP `409` · thrown by `@better-iam/adapter-libsql` , `@better-iam/adapter-postgres` , `@better-iam/adapter-sqlite` Example messages: * A nested database operation failed; transaction rolled back ### VERSION\_CONFLICT [#version_conflict] The record changed since you read it, so your change or acceptance was refused. `policies.update` takes the `version` you edited and refuses it when someone saved a newer one, so edits never overwrite each other. `agreements.accept` refuses a version that is no longer current, so nobody accepts terms they have not seen. **How to fix:** reload the record, show the current version, and let the person decide or accept again. HTTP `409` · thrown by `@better-iam/server` Example messages: * The agreement changed; review the current version before accepting * Policy version changed ## 429 Rate limited [#429-rate-limited] Too many attempts in a short time. The error carries `retryAfterMs` and the HTTP response a `Retry-After` header; wait that long before retrying. | Code | Meaning | | ----------------------------------------- | ------------------------------------------------------------------------------------------ | | [`RATE_LIMITED`](#rate_limited) | There were too many attempts in a short time, so the caller must wait before trying again. | | [`TOO_MANY_REQUESTS`](#too_many_requests) | The person already has too many pending access requests. | ### RATE\_LIMITED [#rate_limited] There were too many attempts in a short time, so the caller must wait before trying again. Sign-in, sign-up, recovery, verification, and MFA flows count attempts per person (and per client IP when `ipAttempts` is set) within a window from `authentication.rateLimits`, 15 minutes by default, which a tenant's `authPolicy.maxAttempts` can tighten. The error carries `retryAfterMs`, the window length and so the longest you may need to wait, and the HTTP response a `Retry-After` header. **How to fix:** wait and tell the person when to try again; an administrator can clear a locked-out person's counters with `identities.unlock` ([failed attempts and lockouts](/docs/guides/authentication/sign-in-methods#failed-attempts-and-lockouts)). HTTP `429` · thrown by `@better-iam/auth` Example messages: * Too many attempts; try again later ### TOO\_MANY\_REQUESTS [#too_many_requests] The person already has too many pending access requests. `accessRequests.create` allows 20 open requests per requester so reviewers are not flooded. Unlike `RATE_LIMITED`, waiting alone does not help. **How to fix:** wait for pending requests to be decided, or cancel ones no longer needed with [`accessRequests.cancel`](/docs/reference/api/access-requests#cancel), then open the new one. HTTP `429` · thrown by `@better-iam/server` Example messages: * Resolve pending requests before opening more ## 402 HTTP 402 [#402-http-402] | Code | Meaning | | --------------------------------------------- | ------------------------------------------------------------------------------ | | [`SPEND_LIMIT_REACHED`](#spend_limit_reached) | An enforced spend budget that covers this usage is already spent (status 402). | ### SPEND\_LIMIT\_REACHED [#spend_limit_reached] An enforced spend budget that covers this usage is already spent (status 402). `billing.record` and `iam.billing.record` with `enforceBudgets: true` check the enforced budgets that cover the usage: tenant budgets up the tree, the person's own, and their teams' and department's. Budget standings may lag recorded usage by up to 30 seconds. **How to fix:** raise or disable the budget (`billing.updateBudget`), wait for the next budget window, or ask the budget's owners; `billing.check` tells callers in advance ([billing](/docs/reference/api/billing)). HTTP `402` · thrown by `@better-iam/server` Example messages: * The budget “…” is spent ## 413 HTTP 413 [#413-http-413] | Code | Meaning | | ----------------------------------------- | -------------------------------------------------------------------------- | | [`INVALID_INPUT`](#invalid_input) | A field in the request is missing, has the wrong type, or is out of range. | | [`PAYLOAD_TOO_LARGE`](#payload_too_large) | The request body is larger than the endpoint accepts. | ### INVALID\_INPUT [#invalid_input] A field in the request is missing, has the wrong type, or is out of range. It is the general validation error of every package: an empty name, a malformed email or id, a `limit` outside its range, a body that is not a JSON object, or options that cannot be combined; the message names the field. A few handlers use it with status 413 for an oversized body. **How to fix:** correct the input; sending the same request again fails the same way. HTTP `400` , `413` · thrown by `@better-iam/auth` , `@better-iam/core` , `@better-iam/next` , `@better-iam/oauth` , `@better-iam/projects` , `@better-iam/saml` , `@better-iam/scim` , `@better-iam/server` Example messages: * Unknown credential token type * limit must be between 1 and 1000 * Only active people with an email can reset a password * limit must be a positive integer * name must not be empty * A passkey authentication response is required ### PAYLOAD\_TOO\_LARGE [#payload_too_large] The request body is larger than the endpoint accepts. API calls and the SCIM administration handler accept up to 64 KiB of JSON, and the Next.js webhook handler accepts 1 MiB by default. **How to fix:** send less per call, for example by splitting a batch into several requests. HTTP `413` · thrown by `@better-iam/scim` , `@better-iam/server` Example messages: * Request body is too large. * Request body too large ## 421 HTTP 421 [#421-http-421] | Code | Meaning | | ------------------------------- | ----------------------------------------------------------------------------------------- | | [`WRONG_REGION`](#wrong_region) | The organization is served by another region's deployment (HTTP 421 Misdirected Request). | ### WRONG\_REGION [#wrong_region] The organization is served by another region's deployment (HTTP 421 Misdirected Request). In a multi-region deployment (`regions`), each organization has a home region, and sign-in for it is served only there: `tenants.lookup`, `domains.discover`, public sign-in calls naming its tenant, and requests on its address answer this code everywhere else. An address that names the wrong region (`acme.signin.eu-west-1.example.com` for an organization homed in `us-east-1`) gets it too. The error carries `region` and, when one can be built, `location`: the organization's sign-in URL in its own region (`IamClientError.region` and `.location` in the client). **How to fix:** redirect the person to `location`. See [sign-in addresses and regions](/docs/operations/deployment/hosts-and-regions). HTTP `421` · thrown by `@better-iam/server` ## 500 HTTP 500 [#500-http-500] | Code | Meaning | | ----------------------------------------------- | --------------------------------------------------------------------------------------------- | | [`INTERNAL_ERROR`](#internal_error) | Something failed on the server that Better IAM does not describe to the caller. | | [`INVALID_HIERARCHY`](#invalid_hierarchy) | The tenant hierarchy does not allow this parent and child, or the stored hierarchy is broken. | | [`SCHEMA_VERSION`](#schema_version) | The database was created by an incompatible version of Better IAM. | | [`STORAGE_CORRUPT`](#storage_corrupt) | A stored record could not be decoded. | | [`STORAGE_ERROR`](#storage_error) | A database operation failed for a reason other than a conflict or a busy database. | | [`STORE_CLOSED`](#store_closed) | The storage adapter was used after it was closed. | | [`TRANSACTION_ACTIVE`](#transaction_active) | A storage adapter was closed from inside one of its own transactions. | | [`TRANSACTION_CLOSED`](#transaction_closed) | A transaction handle was used after its transaction finished. | | [`TRANSACTION_REQUIRED`](#transaction_required) | A write was attempted outside a transaction. | ### INTERNAL\_ERROR [#internal_error] Something failed on the server that Better IAM does not describe to the caller. Over HTTP, any exception that is not an `IamError` (a bug, or one of your callbacks such as `resolveResource` throwing) is answered with this code and a generic message so internals never leak; direct server calls throw the original error instead. The NestJS middleware and the SCIM handlers do the same, and `accessPaths.find` and `impact.preview` use it if their read-only simulation ends abnormally. **How to fix:** retry once, then find the request in your logs or `http` spans by its request ID and report it ([observability](/docs/operations/observability)). HTTP `500` · thrown by `@better-iam/server` Example messages: * Access paths did not complete * Impact preview did not complete ### INVALID\_HIERARCHY [#invalid_hierarchy] The tenant hierarchy does not allow this parent and child, or the stored hierarchy is broken. `tenants.create` and `tenants.reparent` refuse a child type that the parent's type does not allow in `hierarchy.types`, and a move under the tenant's own subtree. With status 500 it means a stored ancestry has a cycle or exceeds the maximum depth, which points to data changed outside Better IAM. **How to fix:** choose a permitted parent or adjust `hierarchy` in your configuration; for the 500 case, repair the tenants' `parentId` values ([tenants and identities](/docs/guides/concepts/tenants-and-identities)). HTTP `400` , `500` · thrown by `@better-iam/server` Example messages: * Child type is not permitted * Child type is not permitted under the new parent * Cannot move a tenant under its own subtree * Invalid tenant hierarchy ### SCHEMA\_VERSION [#schema_version] The database was created by an incompatible version of Better IAM. The SQL adapters record a schema version and refuse to run against a database whose version they do not support. **How to fix:** run the Better IAM version that created the database, or restore a backup made for this version ([database operations](/docs/operations/deployment/database)). HTTP `500` · thrown by `@better-iam/core` Example messages: * Unsupported database schema version ### STORAGE\_CORRUPT [#storage_corrupt] A stored record could not be decoded. The adapters refuse rows whose JSON or escaped values are invalid, or whose columns disagree with the record's own id, tenant, or key, which points to data changed outside Better IAM or damaged storage. **How to fix:** restore the affected data from a backup and find what wrote to the IAM tables directly ([database operations](/docs/operations/deployment/database)). HTTP `500` · thrown by `@better-iam/core` Example messages: * Invalid escaped value * Invalid escaped keys * Invalid stored JSON * Record metadata mismatch ### STORAGE\_ERROR [#storage_error] A database operation failed for a reason other than a conflict or a busy database. Adapters map driver errors to this code with a generic message, so SQL text and record data never reach responses; lost connections, missing tables, and full disks end up here. **How to fix:** check the database's own logs and connectivity, and run `better-iam migrate` if the schema may be missing ([database operations](/docs/operations/deployment/database)). HTTP `500` · thrown by `@better-iam/core` Example messages: * Database operation failed ### STORE\_CLOSED [#store_closed] The storage adapter was used after it was closed. After `close()`, every read and transaction is refused; usually shutdown code closed the store while requests or jobs were still running. **How to fix:** close the store only after in-flight work finishes, and create a new adapter if you need the database again. HTTP `500` · thrown by `@better-iam/adapter-libsql` , `@better-iam/adapter-postgres` , `@better-iam/adapter-sqlite` Example messages: * Database adapter is closed ### TRANSACTION\_ACTIVE [#transaction_active] A storage adapter was closed from inside one of its own transactions. **How to fix:** close the store only after the transaction's callback has returned, typically at process shutdown. HTTP `500` · thrown by `@better-iam/adapter-libsql` , `@better-iam/adapter-postgres` , `@better-iam/adapter-sqlite` Example messages: * Cannot close an adapter inside a transaction ### TRANSACTION\_CLOSED [#transaction_closed] A transaction handle was used after its transaction finished. The `tx` store passed to a transaction callback is valid only until the callback settles; keeping it for later, or not awaiting work started inside the callback, leads here. **How to fix:** await every storage call inside the callback, and never keep `tx` beyond it. HTTP `500` · thrown by `@better-iam/adapter-libsql` , `@better-iam/adapter-postgres` , `@better-iam/adapter-sqlite` Example messages: * Transaction has already completed ### TRANSACTION\_REQUIRED [#transaction_required] A write was attempted outside a transaction. Every `insert`, `put`, and `delete` must run inside `store.transaction()`, so writes are serialized and related changes commit together. **How to fix:** wrap the writes in `store.transaction(async (tx) => { ... })` and write through `tx` ([adapter contract](/docs/operations/extensions#rules-every-adapter-must-keep)). HTTP `500` · thrown by `@better-iam/adapter-libsql` , `@better-iam/adapter-postgres` , `@better-iam/adapter-sqlite` Example messages: * Database writes require transaction() ## 501 HTTP 501 [#501-http-501] | Code | Meaning | | --------------------------------------- | -------------------------------------------------------------------------------- | | [`NO_AUDIT_ARCHIVE`](#no_audit_archive) | Audit archiving was requested, but no archive destination is configured. | | [`UNSUPPORTED`](#unsupported) | The storage adapter cannot list its collections, which a snapshot or copy needs. | ### NO\_AUDIT\_ARCHIVE [#no_audit_archive] Audit archiving was requested, but no archive destination is configured. [`archiveAudit`](/docs/reference/api#archiveaudit) and the `audit-archive` command need the `auditArchive` option, for example `createJsonlAuditArchive({ directory })`. **How to fix:** configure an archive sink ([continuous audit archiving](/docs/operations/jobs#continuous-audit-archiving)). HTTP `501` · thrown by `@better-iam/server` Example messages: * Configure auditArchive (for example createJsonlAuditArchive) to archive audit events ### UNSUPPORTED [#unsupported] The storage adapter cannot list its collections, which a snapshot or copy needs. `store-export`, `store-copy`, and `exportStore` and `copyStore` in code discover what to copy with the store's `collections()` method, which a custom adapter may not implement. **How to fix:** implement `collections()` in the adapter, or pass the collections to copy explicitly to `exportStore` or `copyStore` ([optional adapter methods](/docs/operations/extensions#optional-methods)). HTTP `501` · thrown by `@better-iam/core` Example messages: * This store cannot list its collections; pass the collections to copy explicitly * This adapter cannot list its collections ## 502 HTTP 502 [#502-http-502] | Code | Meaning | | --------------------------------------- | ---------------------------------------------------------------------------------------------------- | | [`OAUTH_DISCOVERY`](#oauth_discovery) | The Microsoft Entra ID metadata for a sign-in connection could not be fetched or is not trustworthy. | | [`WEBHOOK_REJECTED`](#webhook_rejected) | A webhook endpoint answered a delivery with a non-success HTTP status. | ### OAUTH\_DISCOVERY [#oauth_discovery] The Microsoft Entra ID metadata for a sign-in connection could not be fetched or is not trustworthy. Before a Microsoft sign-in, Better IAM downloads the directory's OpenID configuration and checks that the issuer and every endpoint belong to the expected authority; a network failure or unexpected metadata stops the sign-in. **How to fix:** retry later if Microsoft was unreachable; otherwise check the connection's directory settings ([Microsoft Entra ID](/docs/federation/oauth-sign-in#microsoft-entra-id)). HTTP `502` · thrown by `@better-iam/oauth` Example messages: * Microsoft discovery failed. * Unexpected Microsoft issuer. * Microsoft metadata is incomplete. * Microsoft endpoints must use the authority. ### WEBHOOK\_REJECTED [#webhook_rejected] A webhook endpoint answered a delivery with a non-success HTTP status. Deliveries run from the outbox, so this code never reaches the call that caused the event: it is recorded as the delivery's last error, and the delivery is retried with growing delays until its attempts run out. **How to fix:** make the endpoint return a 2xx status, read the recorded error with `webhooks.listDeliveries`, and use `webhooks.redeliver` once it is fixed ([retries](/docs/guides/events/webhooks#retries)). HTTP `502` · thrown by `@better-iam/server` Example messages: * Webhook endpoint responded with status … ## 503 HTTP 503 [#503-http-503] | Code | Meaning | | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | [`PASSWORD_CHECK_UNAVAILABLE`](#password_check_unavailable) | The breached-password service could not be reached, and the deployment refuses passwords it cannot check. | | [`STORAGE_BUSY`](#storage_busy) | The database was too busy to complete the operation in time. | ### PASSWORD\_CHECK\_UNAVAILABLE [#password_check_unavailable] The breached-password service could not be reached, and the deployment refuses passwords it cannot check. `pwnedPasswords({ failClosed: true })` rejects a new password when the breach lookup fails or times out; without `failClosed`, the password is accepted instead. **How to fix:** retry shortly; if it persists, check the server's outbound network access ([password screening](/docs/guides/authentication/sign-in-methods#password-screening)). HTTP `503` · thrown by `@better-iam/auth` Example messages: * Password screening is unavailable ### STORAGE\_BUSY [#storage_busy] The database was too busy to complete the operation in time. Lock timeouts, serialization failures, deadlocks, cancelled statements, and busy or locked SQLite files are reported with this code, and the whole transaction is rolled back. **How to fix:** retry the complete operation after a short pause; if it happens often, raise `lockTimeoutMs` (PostgreSQL) or `busyTimeoutMs` (SQLite, libSQL), or shorten long transactions such as large imports ([storage adapters](/docs/operations/storage)). HTTP `503` · thrown by `@better-iam/core` Example messages: * Database is busy; retry the complete operation # Package exports (/docs/reference/exports) > Every function, class, and constant the Better IAM packages export, what each one does, and which guide explains it. Most code reaches Better IAM through the `betterIam()` instance and its [server API](/docs/reference/api). The packages also export standalone functions: the hooks, guards, and components of each framework integration, policy helpers that run anywhere, verifiers for webhooks, assertions, and session tokens, and the building blocks of custom integrations. This page lists every one of them by entry point, with what it does and the guide that shows it in use. Types are not listed here: the ones you handle most are explained on [Types](/docs/reference/types), and hovering a name in a code example shows its full type. Each entry point is also available through the umbrella `better-iam` package; see [Installation](/docs/guides/installation#all-packages) for the subpath map. ## @better-iam/a2a [#better-iama2a] Agent2Agent (A2A) support: IAM-attested agent cards, card verification and discovery, and authorization for A2A servers. | Export | What it does | Explained in | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | | `const A2A_ACCESS_DENIED` | JSON-RPC error code (HTTP 403) of a message the caller may not send: `error.data.reason` says why (`ACCESS_DENIED`, `UNKNOWN_SKILL`, `NO_RULE_FOR_CALLER`, `INSUFFICIENT_SCOPE`). Tasks of other callers answer A2A's own `-32001`. | [AI agents](/docs/guides/ai-agents) | | `const A2A_CONFIRMATION_REQUESTED` | JSON-RPC error code (HTTP 403) telling an agent that acts for a person that the person must confirm this request first: a confirmation request was filed (`error.data.confirmationId`, `error.data.expiresAt`); retry once approved. | [AI agents](/docs/guides/ai-agents) | | `const agentAttestationUri` | The URI of the attestation extension Better IAM adds to every card it signs. | [AI agents](/docs/guides/ai-agents) | | `class AgentCardError` | Why an agent card was refused by `verifyAgentCard` or `discoverAgent`: `reason` is `unsigned`, `untrusted-key`, `signature`, `attestation`, `expired`, `issuer`, `tenant`, `endpoint`, `malformed` or `fetch`. | [AI agents](/docs/guides/ai-agents) | | `createA2aAuthorizer(options)` | Decisions shared by the gate and by handlers written with an A2A SDK. | [AI agents](/docs/guides/ai-agents) | | `createA2aGate(options)` | The gate: `gate(request, next)` answers the request itself (the agent card, challenges, refusals) or passes it to `next` (the A2A server's handler) with the authenticated caller, recording who started each task on the way back. | [AI agents](/docs/guides/ai-agents) | | `createCardAttestor(options)` | Keeps an agent's own card attested: returns a function that yields the signed card, signing on first use and again when the attestation nears its end. Concurrent calls share one signing request; when re-signing fails while the previous card is still valid, that card is returned. | [AI agents](/docs/guides/ai-agents) | | `createDelegationTokenCache(options)` | For the agent side: returns a function yielding a current delegation token for an audience (and scopes), issuing one on first use and again when it nears its end, so an agent calling a service repeatedly does not ask for a token on every call. Concurrent calls for the same audience share one request. | [AI agents](/docs/guides/ai-agents) | | `class DelegationTokenError` | Why `verifyDelegationToken` refused a token: `reason` is `malformed`, `type`, `issuer`, `untrusted-key`, `signature`, `audience`, `tenant`, `expired`, `not-yet-valid`, `lifetime`, `replay` or `fetch`. | [AI agents](/docs/guides/ai-agents) | | `discoverAgent(url, options)` | Fetches a remote agent's card (`url` itself when it ends in `.json`, otherwise `/.well-known/agent-card.json` on its origin) and verifies it with `verifyAgentCard`, requiring the card's `url` to be on the origin it was fetched from. Call it only with agent addresses you chose: it makes an outbound request. | [AI agents](/docs/guides/ai-agents) | | `const handoffMetadataKey` | The A2A message metadata key that carries a Better IAM hand-off (`delegations.handoff`) from the calling agent to the agent it calls, so the called agent can act for the same person with `delegations.assume`. | [AI agents](/docs/guides/ai-agents) | | `handoffOf(params)` | The hand-off an A2A request carries (the called side): the delegation id in the message's metadata, else in the request's. Open a session for it with the called agent's own key (`delegations.assume`), which refuses hand-offs that are not this agent's. | [AI agents](/docs/guides/ai-agents) | | `memoryTaskOwners(options?)` | In-memory owners: at most `maxTasks` entries (default 100000), each kept `ttlMs` (default seven days). | [AI agents](/docs/guides/ai-agents) | | `taskOwnerOf(caller)` | The owner key of a caller: the same person through the same agent (or none), or the same OAuth client and subject. | [AI agents](/docs/guides/ai-agents) | | `verifyAgentCard(card, options)` | Verifies that `card` was signed by a trusted Better IAM deployment and that its attestation is current: one of the card's signatures verifies over the canonical card (RFC 8785, without `signatures`) with a trusted key, the card carries exactly one attestation, it is neither expired nor from the future, and it matches `issuers`, `tenantId` and `origin` when given. | [AI agents](/docs/guides/ai-agents) | | `verifyDelegationToken(token, options)` | Verifies a delegation token: a compact JWT of type `biam-delegation+jwt`, signed (EdDSA or ES256) by a key of the trusted issuer it names, for exactly `audience` (and `tenantId`), current, and issued for at most an hour. Returns who acts for whom; throws `DelegationTokenError` otherwise. | [AI agents](/docs/guides/ai-agents) | | `withHandoff(message, delegationId)` | Adds a hand-off to an A2A message's metadata (the calling side). | [AI agents](/docs/guides/ai-agents) | ## @better-iam/adapter-libsql [#better-iamadapter-libsql] libSQL / Turso storage adapter. | Export | What it does | Explained in | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | | `libsqlAdapter(options)` | Stores Better IAM's data in libSQL: a local file (optionally encrypted), an embedded replica that syncs with a remote database, or a remote Turso or sqld database. Pass it as `database` to `betterIam()`. | [Storage adapters](/docs/operations/storage) | ## @better-iam/adapter-postgres [#better-iamadapter-postgres] PostgreSQL storage adapter. | Export | What it does | Explained in | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | | `postgresAdapter(options)` | Stores Better IAM's data in PostgreSQL, the usual choice for production. Pass it as `database` to `betterIam()`. Transactions are serialized with a database advisory lock, so several application instances can share one database safely. | [Storage adapters](/docs/operations/storage) | ## @better-iam/adapter-sqlite [#better-iamadapter-sqlite] SQLite storage adapter (better-sqlite3). | Export | What it does | Explained in | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- | | `sqliteAdapter(options)` | Stores Better IAM's data in a SQLite file (through better-sqlite3): the simplest setup, for development, tests, and single-server deployments. Pass it as `database` to `betterIam()`, and move to PostgreSQL when many processes write at once. | [Storage adapters](/docs/operations/storage) | ## @better-iam/auth [#better-iamauth] Passwords, sessions, MFA, passkeys, magic links, recovery, and delivery templates. | Export | What it does | Explained in | | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `class AuthService` | The authentication service behind `iam.auth`: sign-in, sessions, account management, passwordless sign-in, MFA, and passkeys. `betterIam()` creates it; `createAuth` creates one on its own. | | | `characterClasses(password)` | Counts how many kinds of character a password uses (lowercase, uppercase, digits, and everything else), from 0 to 4, for password rules that require variety. | | | `createAuth(options)` | Creates the authentication service (sign-in, sessions, MFA, passkeys, and recovery) on its own, without the rest of the server. `betterIam()` creates one for you and exposes it as `iam.auth`, so you need this only to embed authentication in a custom host. | | | `createMemoryRateLimiter(options?)` | In-process limiter for single-instance deployments and tests. Counters are not shared between processes, and the map is bounded by `maxKeys`. | [Security model](/docs/operations/security) | | `createStoreRateLimiter(store)` | The default limiter: durable counters in the IAM database, committed independently so rejected credentials cannot roll back their attempt. | | | `const credentialTokenKinds` | Maps each credential token type to the session kind it must resolve to (`ses` to `user`, `key` to `api-key`, `rol` to `role`, `sts` to `session-token`, `dlg` to `delegated`), so a token whose prefix disagrees with its stored session is refused. | | | `const credentialTokenScanPattern` | A regular expression source that matches Better IAM credential tokens in text. Add it to your secret scanner, log redaction, or pre-commit hook so leaked session tokens and API keys are caught before anyone uses them. | | | `decryptSecret(value, secret, context?)` | Opens a sealed value with the current secret or, during a rotation, a previous one. | | | `encryptSecret(value, secret, context?)` | Encrypts a value with the deployment secret (authenticated encryption), so stored factors and queued messages can be neither read nor altered without it. `decryptSecret` reverses it. | | | `hashToken(token)` | The SHA-256 hash, in hex, under which a token is stored and looked up. Storing only hashes means a database leak does not reveal usable session tokens or API keys. | | | `isCommonPassword(password)` | The built-in weak-password screen: common passwords, keyboard walks and sequences, and low variety. | | | `newCredentialToken(type)` | Creates a new typed credential token (`biam_ses_…`, `biam_key_…`, `biam_rol_…`, `biam_sts_…`, or `biam_dlg_…`) with a checksum at the end. | | | `newId(prefix?)` | Makes a new random record ID: the prefix you pass (default `id`), an underscore, and 22 random characters. IDs carry 128 random bits, so they cannot be guessed or enumerated. | | | `newToken()` | Makes a new random secret token (256 bits, base64url). Tokens are shown to their owner once; only their hash is stored. | | | `openSecret(value, secrets, context?)` | Opens a sealed value with the first of `secrets` that authenticates it, and says which one (0 is the current secret; higher indexes are previous ones kept during a rotation). Undefined when none does. | | | `parseCredentialToken(value)` | Recognizes a typed credential token and returns its type, or `undefined` when the shape or checksum is wrong, so a mistyped or truncated token can be refused before any database lookup. The type is only a routing hint: the stored session decides what a token is. | | | `pwnedPasswords(options?)` | A Have I Been Pwned "range" client (k-anonymity): hashes the password with SHA-1, sends only the first five hex characters, and matches the returned suffixes locally. Network failures accept the password unless `failClosed`. | [Sign-in methods](/docs/guides/authentication/sign-in-methods) | | `renderDeliveryMessage(message, options?)` | Renders the messages Better IAM queues (`verify-email`, `password-reset`, `email-change`, `magic-link`, `code`, `mfa-code`, `new-sign-in`, `sign-in-failures`, `certification-review`, `certification-reminder`, `delegation-request`, `delegation-confirmation`, `team-join-request`, `team-join-decided`, `team-review-requested`, `spend-alert`, `spend-anomaly`, `billing-statement`, `payment-reminder`, `owner-invitation`, `member-invitation`) into a subject, plain text, and HTML, so a delivery callback can hand them to any provider. | [Support and privacy](/docs/guides/recipes/support-and-privacy) | ### @better-iam/auth/templates [#better-iamauthtemplates] | Export | What it does | Explained in | | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `renderDeliveryMessage(message, options?)` | Renders the messages Better IAM queues (`verify-email`, `password-reset`, `email-change`, `magic-link`, `code`, `mfa-code`, `new-sign-in`, `sign-in-failures`, `certification-review`, `certification-reminder`, `delegation-request`, `delegation-confirmation`, `team-join-request`, `team-join-decided`, `team-review-requested`, `spend-alert`, `spend-anomaly`, `billing-statement`, `payment-reminder`, `owner-invitation`, `member-invitation`) into a subject, plain text, and HTML, so a delivery callback can hand them to any provider. | [Support and privacy](/docs/guides/recipes/support-and-privacy) | ## @better-iam/cli [#better-iamcli] The `better-iam` command line: migrations, bootstrap, audits, config as code, jobs. | Export | What it does | Explained in | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `const builtinCommands` | Every built-in command, in help order. | | | `class CliError` | A command-line failure: an `IamError` (same `code` contract) that may carry a hint, printed on its own line by the `better-iam` binary, telling the person what to run or set next. | | | `cliManifest()` | The machine-readable description of every built-in command (what `better-iam help --json` prints), for docs and tools. | | | `commandHelp(spec)` | `better-iam help `: usage, description, arguments, flags, and examples. | | | `const configFileNames` | Configuration file names the CLI looks for, in this order, in the working directory and then each parent. | | | `configFromEnv(env?)` | Deployment options from environment variables alone (twelve-factor style), for containers and CI jobs that have no configuration file: | | | `createCli(options?)` | Creates a `better-iam`-style program from code: the built-in commands plus your own (`defineCommand`), with the same parsing, help, configuration loading, and output. Use it to ship a project CLI, or to run commands in tests. | | | `createProfileStore(env)` | Opens the saved-session file that `login`, `logout`, `profiles`, and token commands use, located from `env` (see `credentialsPath`), so tools can list, save, or switch profiles the same way the CLI does. | | | `credentialsPath(env)` | Where saved sessions live: `BETTER_IAM_CREDENTIALS`, else `%APPDATA%\better-iam\credentials.json` on Windows, else `$XDG_CONFIG_HOME/better-iam/credentials.json` or `~/.config/better-iam/credentials.json`. | | | `defineCommand(spec)` | Declares a CLI command with typed flags. Export an array of them as `commands` from `better-iam.config.mjs` to add project commands (seeding, reports, migrations of your own) that get the same flag parsing, help, configuration loading, and output formatting as the built-in ones. | | | `findConfigFile(cwd)` | The nearest configuration file in `cwd` or one of its parents, like Prettier and ESLint find theirs. | | | `formatResult(value, format?, query?)` | Turns a command result into the text printed on stdout. Strings are printed as they are. | | | `lintTenantConfig(value)` | Offline checks: the shape (`validateTenantConfig`) plus cross-references between the file's own items. | | | `listRoutes(iam)` | Every route the HTTP API serves for an instance, with whether it needs a credential. | | | `loadConfig(options?)` | Loads a configuration file and creates its instance: the same resolution the CLI uses, for scripts, workers, and tests that want `better-iam.config.mjs` without the command line. Close `instance.store` when done. | | | `loadTenantConfig(path, context)` | Reads a desired tenant configuration for `config-plan` / `config-apply` / `config-validate`: a `.json` file, or a JavaScript/TypeScript module whose default export is the configuration or a (possibly async) factory of it. | | | `localTransport(iam, target, token?)` | Calls API routes in process on a configured instance, as `token` (or with no credential), exposing exactly the routes and credential rules of the HTTP handler; what token commands use without `--url`. | | | `main(argv?, cli?)` | Runs the CLI like the `better-iam` binary: prints `CODE: message` (and a hint) on failure and resolves with the exit status, 0 on success, 2 for a usage mistake, 1 otherwise. For wrappers that ship their own binary. | [Change safety](/docs/guides/governance/change-safety) | | `processIO()` | The process's own IO: stdout, stderr, `process.env`, and prompts when both stdin and stderr are terminals. | | | `remoteTransport(url, token, fetcher?)` | Calls API routes on a running IAM server over HTTP(S) with a bearer token, through the typed client (request IDs, one retry after a short rate limit); what token commands use with `--url` or a profile saved against a server. | | | `runBinary(argv?, cli?)` | What the `better-iam` binary does: runs `main()` on the process arguments, sets the exit status, and fails a command that can never finish instead of exiting 0. For packages that ship the CLI under their own `bin`. | | | `runCli(argv, io?)` | Runs the `better-iam` command-line tool with the given arguments, exactly as the `better-iam` binary does, so scripts and tests can call it in-process. It loads your configuration file as JavaScript, so point it only at trusted configuration. | | | `selectPath(value, path)` | Selects part of a result for `--query`: dotted keys (`summary.create`), array indexes (`roles.0` or `roles[0]`), and `[]` to map over an array (`findings[].kind`). A missing key selects `null` rather than failing, like `jq`. | | | `usageError(message, hint?)` | A usage mistake (unknown flag, missing value, bad number): the binary exits with status 2 for these. | | ## @better-iam/client [#better-iamclient] Typed browser client with session store and passkey helpers. | Export | What it does | Explained in | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- | | `class ClientError` | Another name for `IamClientError`. | [Typed client](/docs/frameworks/client) | | `createIamClient(options?)` | Creates the typed client for browsers and other services. Import your server instance as a type (`createIamClient()`) and every API group, method, input, and result is typed, without bundling any server code. | [Typed client](/docs/frameworks/client) | | `class IamClientError` | The error the typed client throws when the server refuses a call or the request fails. It carries the server's `code` and `status`, the wait before a retry for `RATE_LIMITED`, and the request ID, but never the raw response body. | [Typed client](/docs/frameworks/client) | ### @better-iam/client/passkeys [#better-iamclientpasskeys] | Export | What it does | Explained in | | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | `browserSupportsWebAuthn()` | True when the browser supports passkeys (WebAuthn), so you can decide whether to offer them. | [Typed client](/docs/frameworks/client) | | `browserSupportsWebAuthnAutofill()` | True when the browser can suggest passkeys in the username field's autofill, for a sign-in form without a separate passkey button. | [Passkeys](/docs/guides/authentication/passkeys) | | `platformAuthenticatorIsAvailable()` | True when the device has a built-in authenticator (Touch ID, Windows Hello, an Android fingerprint sensor), a good moment to suggest creating a passkey. | [Typed client](/docs/frameworks/client) | | `startAuthentication(options)` | Asks the browser to sign in with a passkey, using the options your server returned. Send the result back to the server to finish signing in. | [Passkeys](/docs/guides/authentication/passkeys) | | `startRegistration(options)` | Asks the browser to create a passkey, using the options your server returned. Send the result back to the server to save it. | [Typed client](/docs/frameworks/client) | | `const WebAuthnAbortService` | A service singleton to help ensure that only a single WebAuthn ceremony is active at a time. | [Typed client](/docs/frameworks/client) | ### @better-iam/client/session [#better-iamclientsession] | Export | What it does | Explained in | | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | | `createSessionStore(client, options?)` | Framework-agnostic session state. The React and Vue bindings subscribe to it; it can also drive other frameworks or plain scripts. | [Typed client](/docs/frameworks/client) | | `isUnauthenticated(error)` | True when an error means the caller is not signed in any more or must confirm who they are (the server answered 401 or 403). Anything else is a network or server failure, worth a retry rather than a sign-in page. | [Typed client](/docs/frameworks/client) | ## @better-iam/core [#better-iamcore] Models, the policy engine, the storage contract, the audit chain, and shared errors. | Export | What it does | Explained in | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | `appendAuditEvent(tx, event)` | Records an audit event at the end of its tenant's chain. Every audit insert must go through here. | | | `applyMigrations(execute, dialect, options?)` | Creates the schema and applies missing named migrations, recording each in `iam_migrations`. Run it inside the adapter's serialized write transaction. Returns the names applied now. | [Adapters and plugins](/docs/operations/extensions) | | `auditEventHash(event)` | The hash of an event: SHA-256 over the canonical JSON of every field except `hash` itself. | | | `const auditGenesis` | The `previousHash` of the first event of every chain. | | | `const authMethods` | The sign-in methods a tenant policy can allow or restrict; impersonation is an administrative action, not a sign-in. | | | `canonicalizeJson(value)` | The JSON Canonicalization Scheme (RFC 8785): the single serialization of a JSON value that signers and verifiers agree on. Object members are sorted by the UTF-16 code units of their names at every depth, numbers use the ECMAScript shortest form and strings the escaping of `JSON.stringify`, and there is no whitespace. | | | `canonicalJson(value)` | Deterministic JSON: object keys sorted recursively, `undefined` properties omitted, arrays kept in order. | [Audit chain](/docs/guides/events/audit-chain) | | `chainAuditEvent(tx, event)` | Assigns the next chain position to an event and advances the tenant's chain head. The caller stores the returned event; `appendAuditEvent` does both. Must run inside the transaction that records the event. | | | `const COLLECTIONS_SQL` | Distinct collection names in the records table. | | | `compareIds(left, right)` | Compares two IDs in the byte order databases use (SQLite's BINARY, PostgreSQL's "C" collation), so sorting in memory agrees with `ORDER BY` in SQL. | | | `copyStore(source, target, options?)` | Copies every record from `source` into an empty, migrated `target` (for example SQLite to PostgreSQL). Reads in one source transaction and writes in one target transaction, so the copy is consistent and all-or-nothing. | [Storage adapters](/docs/operations/storage) | | `decodeJsonbDocument(data)` | The original record JSON of a document written by `encodeJsonbDocument`. | | | `definePolicy(document)` | Writes a policy document in TypeScript, with type checking. It validates the document when your code loads, so a malformed policy fails at startup rather than when it is saved, and returns a detached copy. | [Authorization](/docs/guides/authorization) | | `delegationActor(chain)` | The `act` claim for a chain of agents, the person's own delegate first and the agent acting now last. | | | `const delegationTokenLimits` | Lifetimes (seconds) and the longest chain of actors a delegation token carries. | | | `const delegationTokenScopePattern` | One scope a delegation token may carry: an action, or an action pattern whose only wildcard is `*`. | | | `const delegationTokenType` | The JWT `typ` header of delegation tokens: verifiers refuse any other. | | | `describeRecords(execute, adapter, settings)` | The adapter-independent part of `IamStore.describe()`: schema version, applied migrations, and record counts per collection. Missing tables read as empty, so an unmigrated database reports empty lists; any other failure rejects. | [Adapters and plugins](/docs/operations/extensions) | | `encodeJsonbDocument(data)` | Record JSON as the PostgreSQL adapter stores it: valid jsonb, reversible by `decodeJsonbDocument`. | [Database operations](/docs/operations/deployment/database) | | `encodeLegacyPostgresRows(name, execute)` | Migration hook for PostgreSQL: rewrites rows written before the value encoding existed, whose text jsonb would reject, so the jsonb index of `0002_query_indexes` can be built. | | | `evaluatePolicy(input)` | Decides whether a request is allowed by a set of policy documents, with the same engine the server uses. It needs no database, so it runs anywhere (the [playground](/playground) uses it) and returns the decision, its reason, and the statements that matched. Grants add up; each boundary can only narrow them. | [Policy documents](/docs/guides/authorization/policies) | | `const EXPIRY_FIELDS` | Numeric fields SQL adapters index for `findOrdered` across a whole collection, so retention sweeps find expired and long-delivered records without scanning (`0004_expiry_indexes`). | | | `exportStore(store, write, options?)` | Writes a snapshot through `write`, one line at a time (without newlines). Runs in one store transaction, so the snapshot is consistent; that also holds the store's write lock until done. | [Storage adapters](/docs/operations/storage) | | `findOrdered(store, collection, filter, order)` | Records matching `filter` whose numeric `order.field` lies in `[from, to]`, ordered by that field (ties by id, ascending) and paged. Uses the store's `findOrdered` when it has one and otherwise orders a plain `find` in memory. | [Adapters and plugins](/docs/operations/extensions) | | `class IamError` | The error every Better IAM operation throws when it refuses a request. `code` is a stable name such as `ACCESS_DENIED`, `status` is the matching HTTP status, and `message` is for people. Check `code` in your own code; the [error reference](/docs/reference/errors) lists every code and how to handle it. | [Adapters and plugins](/docs/operations/extensions) | | `importStore(store, lines)` | Loads a snapshot into an empty, migrated store in one transaction: a malformed line, a record the store refuses, or a missing or mismatched trailer rolls everything back. | [Storage adapters](/docs/operations/storage) | | `const INDEXED_FIELDS` | High-cardinality string fields looked up across tenants or inside large collections (sessions, identities, memberships, bindings, protocol artifacts, delivery queues). SQL adapters may index them; the list only affects performance, never results. | [Database operations](/docs/operations/deployment/database) | | `instrumentStore(store, onCall, now?)` | Wraps a store so every data call is reported to `onCall` (reads, writes, `collections`, and `describe`; `transaction`, `migrate`, and `close` pass straight through), for slow-query logging, capacity planning, and tests that bound how much work an operation does. | [Database operations](/docs/operations/deployment/database) | | `ipCounterKey(address)` | The key a per-address counter uses for a client: an IPv4 address as itself (the IPv4-mapped IPv6 form folded to it), an IPv6 address as its /64 (`2001:db8:1:2::/64`), since one subscriber or host normally controls a whole /64 and can rotate through it at will. | | | `ipMatches(address, network)` | True when `address` lies inside `network` (an address or CIDR block of the same family). An IPv4-mapped IPv6 address (`::ffff:198.51.100.7`) matches the IPv4 networks that contain its IPv4 address, and an IPv4 address the IPv4-mapped networks (`::ffff:198.51.100.0/120`) that contain it. | | | `isIpRange(value)` | True for an IPv4/IPv6 address or CIDR block, as accepted by the `IpAddress` operator. | | | `isMissingTable(error)` | A query failed because a table does not exist (PostgreSQL 42P01, SQLite "no such table"). | | | `const JSONB_ESCAPE_KEY` | Reserved object key of the PostgreSQL value encoding (`encodeJsonbDocument`), which stores strings jsonb cannot hold. Filters whose objects use it are evaluated in memory. | | | `const LOOKUP_INDEX_STEPS` | The lookup fields each schema step indexes (SQLite and libSQL; PostgreSQL's document index covers every field). A released step never changes: new fields get a new step. | | | `matchesFilter(record, filter)` | The in-memory definition of filter semantics. Drivers' SQL must agree with it. | | | `matchPattern(pattern, value, context?)` | Tests whether an action or resource name matches a policy pattern, exactly as policy evaluation does. The whole name must match; `*` matches any run of characters (including `/` and `:`) and `?` exactly one, and `${...}` variables are filled in from the optional context first. There are no regular expressions. | | | `const MAX_QUERY_CONDITIONS` | Most typed conditions `planQuery` hands to a driver; further keys are filtered in memory. | [Adapters and plugins](/docs/operations/extensions) | | `const ORDERED_FIELDS` | Numeric fields SQL adapters index for `findOrdered` within a tenant (audit time and sequence). | | | `planQuery(collection, filter, capabilities?)` | Splits a validated filter into driver conditions and keys left to the in-memory filter. | [Adapters and plugins](/docs/operations/extensions) | | `postgresSelect(query)` | SELECT for PostgreSQL. Scalar tests share one jsonb containment test, served by a GIN index. | [Adapters and plugins](/docs/operations/extensions) | | `const QUERYABLE_FIELD` | Field names a driver may inline into SQL paths. Other names are filtered in memory. | | | `readDelegationTokenClaims(claims, expected)` | Checks the claims of a (signature-verified) delegation token against what the verifier expects: well formed, from an accepted issuer, for exactly this audience (and tenant), current within the clock tolerance, and issued for at most `delegationTokenLimits.maxSeconds`. | | | `class RecordStore` | The base class the reference storage adapters share. It implements the storage contract over one records table, so SQLite, libSQL, and PostgreSQL behave identically; extend it to support another SQL database. | [Adapters and plugins](/docs/operations/extensions) | | `resolvePolicyValue(value, context)` | Resolves the variables of a string condition value. Returns undefined when any variable is unresolved. | | | `schemaMigrations(dialect)` | The schema steps for one SQL dialect, in order. `applyMigrations` runs the ones a database has not applied yet. | | | `const SNAPSHOT_FORMAT` | Portable store snapshots: every record of every collection as JSON Lines, independent of the adapter, for backups, moving from SQLite to PostgreSQL, and test fixtures. Records are copied verbatim, so audit hash chains, token hashes, and encrypted secrets stay valid. | | | `const SNAPSHOT_VERSION` | The version of the snapshot format that `better-iam store-export` writes. `store-import` accepts only this version, so a snapshot in another format is refused before anything is written. | | | `sqliteSelect(query)` | SELECT for SQLite and libSQL (JSON1). `json` conditions are not supported. | [Adapters and plugins](/docs/operations/extensions) | | `storableString(value)` | True when the string has no U+0000 and no unpaired surrogate. Such strings can be stored as record values, but database text comparisons cannot represent them, so `find` evaluates filters holding them in memory. Identifiers must satisfy it. | | | `storageError(error)` | Turns a database failure into an `IamError` that is safe to show: a unique-key clash becomes `CONFLICT` (409), a busy or deadlocked database `STORAGE_BUSY` (503, retry the whole operation), and no message reveals SQL or record data. | | | `summarizeStoreCalls(calls)` | Totals of a list of calls, grouped by method and collection (`find:sessions`). | | | `tenantTreeActive(store, tenantId)` | Walks a tenant and its ancestors: true only when every one exists and is active. Cycles read as inactive. | | | `validatePolicy(value)` | Checks that a value is a well-formed policy document and throws `INVALID_POLICY` describing the first problem. The server runs it before storing a document and again before evaluating one. | | | `validPolicyVariables(value)` | Every `${...}` in a value must be a well-formed variable reference. | | | `verifyAuditChain(events, options?)` | Verifies a run of events from one tenant: contiguous sequences, each `previousHash` equal to the previous event's hash (or `previousHash` of the first event when the run starts mid-chain), and every hash recomputable. Events are sorted by sequence first, so exports and database reads can be passed as they come. | [Audit chain](/docs/guides/events/audit-chain) | ### @better-iam/core/conformance [#better-iamcoreconformance] | Export | What it does | Explained in | | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | `adapterConformanceCases()` | A fresh copy of every conformance case, in a stable order. | [Adapters and plugins](/docs/operations/extensions) | | `class ConformanceFailure` | The error the storage conformance suite throws when an adapter behaves differently from the reference adapters. Its message names the failing check, so a custom adapter's test run shows exactly what to fix. | | | `runAdapterConformance(createStore)` | Runs every case against fresh stores and collects failures instead of stopping at the first. | [Adapters and plugins](/docs/operations/extensions) | ## @better-iam/mcp [#better-iammcp] Tool-level authorization for Model Context Protocol servers: Better IAM credentials, agents and OAuth tokens. | Export | What it does | Explained in | | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `createMcpAuthorizer(options)` | The tool-level decisions of `createMcpGate` without the HTTP handling, for tool handlers written directly against an MCP SDK: `authenticate(request)` identifies the caller, `canCall(caller, name, args)` decides one tool call, and `visibleTools(caller, tools)` filters a tool list. | [AI agents](/docs/guides/ai-agents) | | `createMcpGate(options)` | Puts Better IAM in front of a Model Context Protocol server that speaks Streamable HTTP and decides, tool by tool, who may see and call what. | [AI agents](/docs/guides/ai-agents) | | `protectedResourceMetadataUrl(resource)` | Where RFC 9728 metadata lives for a resource: `/.well-known/oauth-protected-resource` + the resource path. | [Dynamic registration and MCP](/docs/federation/mcp-authorization) | ## @better-iam/middleware [#better-iammiddleware] Framework-neutral middleware core with Express, Hono, and Fastify adapters. | Export | What it does | Explained in | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | `checkRequestOrigin(request, trustedOrigins?)` | The CSRF boundary for application routes: a state-changing request authenticated by the session cookie (no `Authorization` header) must come from this application's origin, the IAM origin, or a trusted origin. | [Express, Hono, and Fastify](/docs/frameworks/node) | | `checkStepUp(session, requirement, now?)` | Checks a session against a step-up requirement; null when it qualifies. Accepts the `getSession` result or its inner session record. Impersonated sessions always fail a recency requirement, as the server's own check does. | [Frameworks](/docs/frameworks) | | `createRequestHelpers(resolveIam, binding, options?)` | Builds the per-request helpers over a framework's request and response. Framework adapters call this once per request. | [Express, Hono, and Fastify](/docs/frameworks/node) | | `enforceGuard(helpers, request, spec?, origin?)` | Runs a guard; resolves with the session or throws the refusal (`IamRequestError` or the server's `IamError`). With `origin`, a cookie-authenticated unsafe request from an untrusted origin is refused first. | [Express, Hono, and Fastify](/docs/frameworks/node) | | `errorBody(error)` | The IAM API's JSON error envelope, used by every adapter for refusals. | [Express, Hono, and Fastify](/docs/frameworks/node) | | `errorCode(error)` | Reads the `code` of an error (such as `ACCESS_DENIED`) without assuming it is an `IamError`; undefined for anything else. Useful in your own error handlers, where the thrown value can be anything. | [Express, Hono, and Fastify](/docs/frameworks/node) | | `errorStatus(error)` | Reads the HTTP `status` of an error without assuming it is an `IamError`; undefined for anything else. | [Express, Hono, and Fastify](/docs/frameworks/node) | | `class IamRequestError` | A refusal raised by the request helpers and guards, with the IAM `code` and HTTP `status`. `reason` is set for step-up failures (`mfa`, `recent`, `impersonation`). Framework adapters turn it into the IAM JSON error envelope or, for page navigations, a redirect to the login / step-up page. | [Express, Hono, and Fastify](/docs/frameworks/node) | | `isAuthenticationError(error)` | True for the errors `getSession` raises when there is no usable session. | [Express, Hono, and Fastify](/docs/frameworks/node) | | `isIamRefusal(error)` | True for any error carrying an IAM code and an HTTP status (server `IamError`, client errors, `IamRequestError`). | [Express, Hono, and Fastify](/docs/frameworks/node) | | `nodeHeaders(record)` | Converts a Node `IncomingMessage`-style header record into `Headers`. | [Express, Hono, and Fastify](/docs/frameworks/node) | | `parseCookieHeader(header)` | Splits a `Cookie` header into name/value pairs (values left encoded). | [Express, Hono, and Fastify](/docs/frameworks/node) | | `refusalResponse(error, request, pages)` | How a guard should answer a refusal: a redirect for page navigations when a page is configured, else JSON. | [Express, Hono, and Fastify](/docs/frameworks/node) | | `safeRedirectPath(value, fallback?)` | A same-site relative path safe to redirect to, or `fallback`. Refuses schemes, `//host`, and backslash tricks. | [Express, Hono, and Fastify](/docs/frameworks/node) | | `setCookieSummary(header, now?)` | The name, raw value, and whether a `Set-Cookie` header deletes the cookie (`Max-Age=0` or a past `Expires`). | [Express, Hono, and Fastify](/docs/frameworks/node) | | `tenantOf(session)` | The tenant a session acts in. | [Express, Hono, and Fastify](/docs/frameworks/node) | | `underPath(pathname, prefix)` | True when `pathname` is `prefix` or below it on a segment boundary. | [Express, Hono, and Fastify](/docs/frameworks/node) | | `withQuery(target, params)` | Appends query parameters, skipping undefined values. | [Express, Hono, and Fastify](/docs/frameworks/node) | ### @better-iam/middleware/express [#better-iammiddlewareexpress] | Export | What it does | Explained in | | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | `createIamExpress(source, options?)` | Express / Connect integration. `middleware` serves the IAM API (through `iam.nodeHandler`, so node protocol mounts such as the OAuth provider work) and gives every other request `req.iam`; `requireSession` and `authorize` are route guards; `errorHandler` answers IAM refusals thrown by your routes. | [Express, Hono, and Fastify](/docs/frameworks/node) | ### @better-iam/middleware/fastify [#better-iammiddlewarefastify] | Export | What it does | Explained in | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | `createIamFastify(source, options?)` | Fastify integration. `plugin` (register it with `app.register(iamFastify.plugin)`) answers the IAM API in an `onRequest` hook, before Fastify parses bodies, through `iam.nodeHandler`; every other request gets `request.iam`. | [Express, Hono, and Fastify](/docs/frameworks/node) | ### @better-iam/middleware/hono [#better-iammiddlewarehono] | Export | What it does | Explained in | | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | `createIamHono(source, options?)` | Hono integration (Node, Bun, Deno, Workers). `middleware` answers the IAM API with `iam.handler` and sets `c.get('iam')` for every other request; `requireSession` and `authorize` are route guards; `onError` turns IAM refusals thrown by handlers into the JSON error envelope. | [Express, Hono, and Fastify](/docs/frameworks/node) | ## @better-iam/nestjs [#better-iamnestjs] NestJS module, guard, decorators, and testing utilities. | Export | What it does | Explained in | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | | `AssertionClaims(...dataOrPipes)` | The verified assertion claims (`sub`, `tid`, `roles`, `groups`, `ext`, ...), or null on a `@Public()` handler. | [NestJS](/docs/frameworks/nestjs) | | `Authorize(action, options?)` | Enforces `action` before the handler runs. Rules accumulate: every `@Authorize` on the class and the method must allow. The resource defaults to the tenant itself (`iam/{tenantId}`) and the tenant to the module's resolver. | [NestJS](/docs/frameworks/nestjs) | | `createIamRequestHandler(iam)` | A Node request handler that serves the IAM HTTP API (and mounted OAuth/SAML/SCIM protocols) from inside a Nest application, for `app.use('/api/iam', createIamRequestHandler(iam))` or the module's `mount` option. It works with bodies already consumed by Nest's parsers. | [NestJS](/docs/frameworks/nestjs) | | `credentialOf(request)` | Turns a Nest request into the `{ headers }` credential that `iam.api` calls expect, so your own services can call the API as the person making the request. | [NestJS](/docs/frameworks/nestjs) | | `Credentials(...kinds)` | Restricts which credential kinds may call the handler, e.g. `@Credentials('api-key')` for machine endpoints. | [NestJS](/docs/frameworks/nestjs) | | `CurrentIdentity(...dataOrPipes)` | The caller's identity, or null on a `@Public()` handler called without a credential. | [NestJS](/docs/frameworks/nestjs) | | `CurrentPrincipal(...dataOrPipes)` | The authenticated `{ identity, session }`, or null on a `@Public()` handler called without a credential. | [Frameworks](/docs/frameworks) | | `CurrentSession(...dataOrPipes)` | The caller's session record, or null on a `@Public()` handler called without a credential. | [NestJS](/docs/frameworks/nestjs) | | `FilterAccessible(action, options)` | Drops items the caller may not perform `action` on from a list response, using the reverse query `listAccessible` over the registered resources of a managed type (one query per page of 1000, never one decision per item, so the audit log is not flooded with denials). | [NestJS](/docs/frameworks/nestjs) | | `const IAM_ASSERTION_OPTIONS` | Injection token for the resolved `IamAssertionOptions`. | [NestJS](/docs/frameworks/nestjs) | | `const IAM_INSTANCE` | Injection token for the `betterIam()` instance. | [NestJS](/docs/frameworks/nestjs) | | `const IAM_OPTIONS` | Injection token for the resolved `IamModuleOptions`. | [NestJS](/docs/frameworks/nestjs) | | `class IamAssertionGuard` | For downstream services that receive stateless assertions from a Better IAM deployment: verifies the token without a database or network round trip and exposes its claims. It never contacts the IAM server, so revocation takes effect when the (short-lived) assertion expires. | [NestJS](/docs/frameworks/nestjs) | | `class IamAssertionModule` | Configures `IamAssertionGuard` for a service that trusts assertions from a Better IAM deployment. | [NestJS](/docs/frameworks/nestjs) | | `class IamEventsExplorer` | Subscribes every `@OnIamEvent` provider method to the IAM event stream at bootstrap and, when `dispatchIntervalMs` is set, drives `iam.events.dispatch()` until shutdown. | [NestJS](/docs/frameworks/nestjs) | | `class IamExceptionFilter` | Renders `IamError`s thrown from handlers and providers (for example `IamService.require` or direct `iam.api.*` calls) as the IAM server's `{ error: { code, message } }` body with the error's status instead of a 500. | [NestJS](/docs/frameworks/nestjs) | | `class IamFilterInterceptor` | The interceptor behind `@FilterAccessible`: it removes items the caller may not act on from a list response. You apply it through the decorator rather than directly. | [NestJS](/docs/frameworks/nestjs) | | `class IamGuard` | Authenticates every request it guards and enforces the handler's metadata: `@Public()`, `@Credentials()`, `@RequireMfa()`, and `@Authorize()` rules. Failures render as the IAM server's `{ error: { code, message } }` body with its status (401 unauthenticated, 403 denied, 429 rate limited). | [NestJS](/docs/frameworks/nestjs) | | `class IamHttpMiddleware` | Nest middleware form of `createIamRequestHandler`, registered by `IamModule.forRoot({ mount: true })`. | [NestJS](/docs/frameworks/nestjs) | | `class IamModule` | Better IAM for NestJS: provides `IamService`, `IamGuard`, and the exception filter; optionally installs the guard globally, mounts the IAM HTTP API, and binds `@OnIamEvent` handlers. | [NestJS](/docs/frameworks/nestjs) | | `class IamService` | Request-scoped IAM calls for controllers and providers: every method takes the incoming request (Express, Fastify, or anything with `headers`) and forwards its credential, so decisions are always made for the actual caller. | [NestJS](/docs/frameworks/nestjs) | | `isAuthenticationError(error)` | True for the errors authentication raises when a request carries no usable credential. | [NestJS](/docs/frameworks/nestjs) | | `isIamError(error)` | True when an error is an `IamError`. It checks the shape rather than the class, so errors from a second copy of `@better-iam/core` in `node_modules` are recognized too. | [NestJS](/docs/frameworks/nestjs) | | `OnIamEvent(pattern)` | Subscribes a provider method to audit events whose action matches the pattern(s), e.g. `identity:*`. Delivery is post-commit and at-least-once, driven by `iam.events.dispatch()` (see `IamModule`'s `dispatchIntervalMs`). | [NestJS](/docs/frameworks/nestjs) | | `Public()` | Skips the session requirement; the guard still resolves a principal when a credential is present. | [NestJS](/docs/frameworks/nestjs) | | `RequireClaims(requirements)` | Requirements the verified assertion must meet in addition to signature, audience, and lifetime. | [NestJS](/docs/frameworks/nestjs) | | `RequireMfa()` | Requires a session that completed multi-factor authentication (user sessions only). | [NestJS](/docs/frameworks/nestjs) | | `TenantId(...dataOrPipes)` | The tenant the request's `@Authorize` rules were evaluated in (or the session tenant when there were none). | [NestJS](/docs/frameworks/nestjs) | | `toHttpException(error)` | The HTTP exception Nest renders for an IAM error: the server's own `{ error: { code, message } }` body and status. | [NestJS](/docs/frameworks/nestjs) | ### @better-iam/nestjs/testing [#better-iamnestjstesting] | Export | What it does | Explained in | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------- | | `createTestingIam(options?)` | An in-memory stand-in for a `betterIam()` instance for unit and e2e tests of Nest applications: no database, no password hashing, principals chosen by bearer token, and decisions made by a callback. | [NestJS](/docs/frameworks/nestjs) | ## @better-iam/next [#better-iamnext] Next.js App Router helpers: guarded pages, routes, actions, and edge checks. | Export | What it does | Explained in | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | `class AssertionError` | Raised by the edge verifiers; `code` and `status` match the server's `IamError` for the same failure. | [Next.js](/docs/frameworks/nextjs) | | `const authFields` | Form field names shared by the actions and the client forms. `intent` selects the step a submission performs. | [Next.js](/docs/frameworks/nextjs) | | `authHiddenFields(state)` | The hidden inputs a form renders for `state` so its next submission continues the flow: `next`, the pending challenge on the MFA and enrollment steps, the email and organization on the steps after the credentials, and the "keep me signed in" choice (after the credentials step, which renders its own checkbox). | [Next.js](/docs/frameworks/nextjs) | | `const authStepFields` | Hidden fields the MFA and enrollment steps post back besides `tenantId`, `challenge`, `next`, and `keepSignedIn`, so a refused code re-renders the same step without client JavaScript. `authHiddenFields(state)` lists them all. | [Next.js](/docs/frameworks/nextjs) | | `checkStepUp(session, requirement, now?)` | Checks a session against a step-up requirement; null when it qualifies. Accepts the `getSession` result, its inner session record, or an `apiRoute` principal. | [Advanced](/docs/frameworks/nextjs/advanced) | | `createAuthActions(host, options?)` | Headless server actions for sign-in, MFA, passwordless codes, password reset, sign-up, email verification, invitations, step-up, and sign-out. Pass `createIamNext()`'s result; the actions call the IAM handler in process, so session cookies are written through `cookies()` and forms work without client JavaScript. | [Next.js](/docs/frameworks/nextjs) | | `createBackground(resolve, options?)` | Background delivery and maintenance for a Better IAM instance in Next.js. Call `background.schedule()` after actions that queue email (sign-up, password reset, invitations) so it goes out after the response, and mount `background.cron()` for periodic jobs and as a safety net when `after()` cannot run. | [Next.js](/docs/frameworks/nextjs) | | `createIamMiddleware(options)` | Edge-safe presence check for the session cookie: signed-out visitors are redirected before a protected page renders. It is a routing convenience, not authorization; pages still call `requireSession` or `require`. | [Next.js](/docs/frameworks/nextjs) | | `createIamNext(source, options?)` | Server-side helpers for the App Router. Server components and route handlers read the session from the request cookies, enforce with `require`, batch advisory decisions with `can`, and mount the IAM handler with `handlers()`. | [Next.js](/docs/frameworks/nextjs) | | `createWebhookHandler(options)` | A route handler that receives Better IAM webhooks: `export const POST = createWebhookHandler({ secret, onEvent })`. Unsigned, stale, or oversized requests answer 401/413 without calling `onEvent`; a throwing `onEvent` answers 500 so the sender retries with backoff. | [Advanced](/docs/frameworks/nextjs/advanced) | | `isAuthenticationError(error)` | True for the errors `getSession` raises when there is no usable session. | [Next.js](/docs/frameworks/nextjs) | | `isNextControlError(error)` | True for Next's control-flow throws (`redirect`, `notFound`, `forbidden`, `unauthorized`), which must propagate. | [Next.js](/docs/frameworks/nextjs) | | `matchPath(pattern, pathname)` | Glob over URL paths: `*` matches within one segment, `**` across segments. | [Next.js](/docs/frameworks/nextjs) | | `parseSetCookie(header)` | Parses one `Set-Cookie` header into the name, decoded value, and options Next's `cookies().set` accepts. | [Next.js](/docs/frameworks/nextjs) | | `const pathnameHeader` | The request header middleware forwards so server components know the path being rendered. | [Next.js](/docs/frameworks/nextjs) | | `safeRedirectPath(value, fallback?)` | A same-origin path safe to redirect to after sign-in, or `fallback`. Rejects absolute URLs, protocol-relative `//host`, backslash tricks, and control characters, so a `?next=` parameter cannot become an open redirect. | [Middleware](/docs/frameworks/nextjs/middleware) | | `sessionCookieName(secure)` | The cookie name the server issues: host-prefixed on HTTPS, plain on loopback development. | [Next.js](/docs/frameworks/nextjs) | | `verifyAssertionToken(token, options)` | Verifies an assertion issued by `assertions.issue` with Web Crypto, so middleware and edge handlers can trust it without a database. Same rules as the server's `verifyAssertion`: HS256 only, audience, optional issuer, and time. | [Advanced](/docs/frameworks/nextjs/advanced) | | `verifyWebhook(input)` | Checks an `X-Better-IAM-Signature` header (`v1=`) with Web Crypto. Equivalent to the server's `verifyWebhookSignature`, for edge runtimes. | [Next.js](/docs/frameworks/nextjs) | | `withAssertion(options, handler)` | Route handler guard for services that receive assertions from a Better IAM application: the bearer token is verified offline and its claims passed to the handler. Failures answer 401 with the server's error envelope. | [Advanced](/docs/frameworks/nextjs/advanced) | ### @better-iam/next/client [#better-iamnextclient] | Export | What it does | Explained in | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `Can(props)` | Renders children only when the advisory decision allows the action; `fallback` otherwise and `loading` meanwhile. Re-exported from `@better-iam/react`. | [Frameworks](/docs/frameworks) | | `IamNextProvider(props)` | `IamProvider` for the App Router: pass `initialSession` from `iamNext.sessionForClient()` in a server layout, and server components refresh automatically when the session changes on the client. | [Next.js](/docs/frameworks/nextjs) | | `InvitationForm(props)` | Accepts an invitation with a name and a new password, then enrolls or verifies the second factor when the organization requires one, and shows the recovery codes. | [Next.js](/docs/frameworks/nextjs) | | `InvitationFormView(props)` | The markup of `InvitationForm` for a given action state. | | | `PasswordResetForm(props)` | Sets a new password from a reset link. Existing sessions end; the person signs in again. | [Server actions and forms](/docs/frameworks/nextjs/server-actions) | | `PasswordResetFormView(props)` | The markup of `PasswordResetForm` for a given action state. | | | `PasswordResetRequestForm(props)` | Asks for a password reset email. The reply never reveals whether the account exists. | [Server actions and forms](/docs/frameworks/nextjs/server-actions) | | `PasswordResetRequestFormView(props)` | The markup of `PasswordResetRequestForm` for a given action state. | | | `ReauthenticateForm(props)` | Confirms the signed-in person's password, then their second factor when the account has one. The confirmation issues a new session, whose cookie follows `keepSignedIn`. | [Server actions and forms](/docs/frameworks/nextjs/server-actions) | | `ReauthenticateFormView(props)` | The markup of `ReauthenticateForm` for a given action state. | [Server actions and forms](/docs/frameworks/nextjs/server-actions) | | `SignInForm(props)` | Password sign-in with an optional emailed sign-in code, then the second factor (authenticator, emailed code, or recovery code) or first-time authenticator enrollment, as `action` directs. Works without client JavaScript. | [Organizations in the URL](/docs/frameworks/nextjs/organizations) | | `SignInFormView(props)` | The markup of `SignInForm` for a given action state. | [Server actions and forms](/docs/frameworks/nextjs/server-actions) | | `SignUpForm(props)` | Self-registration: name, email, and password, usually followed by an email to confirm the address. | [Next.js](/docs/frameworks/nextjs) | | `SignUpFormView(props)` | The markup of `SignUpForm` for a given action state. | | | `useAccessible(options)` | The registered resources of a managed type the signed-in principal may act on; refetched when the input or identity changes. Re-exported from `@better-iam/react`. | [Next.js](/docs/frameworks/nextjs) | | `useAuthorize(options)` | Batched advisory decisions for rendering menus and buttons. Re-evaluated when the checks or the signed-in identity change; the server still enforces every operation. Re-exported from `@better-iam/react`. | [Frameworks](/docs/frameworks) | | `useIamClient()` | The client passed to the provider, typed as the caller declares it. Re-exported from `@better-iam/react`. | [Next.js](/docs/frameworks/nextjs) | | `useRouterSync()` | Calls `router.refresh()` whenever the signed-in identity changes in the client (sign-in, sign-out, account switch, or a session that expired while the tab was open), so server components re-render with the new cookies. | [Next.js](/docs/frameworks/nextjs) | | `useSession()` | The current session snapshot plus refresh, sign-out, and manual replacement (after a sign-in response). Re-exported from `@better-iam/react`. | [Next.js](/docs/frameworks/nextjs) | | `useSignOut(options?)` | Signs out through the client session store, then navigates (when `redirectTo` is given) and refreshes server components so no page keeps rendering the previous session. | [Advanced](/docs/frameworks/nextjs/advanced) | ### @better-iam/next/edge [#better-iamnextedge] | Export | What it does | Explained in | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | `class AssertionError` | Raised by the edge verifiers; `code` and `status` match the server's `IamError` for the same failure. | [Next.js](/docs/frameworks/nextjs) | | `createIamMiddleware(options)` | Edge-safe presence check for the session cookie: signed-out visitors are redirected before a protected page renders. It is a routing convenience, not authorization; pages still call `requireSession` or `require`. | [Next.js](/docs/frameworks/nextjs) | | `createWebhookHandler(options)` | A route handler that receives Better IAM webhooks: `export const POST = createWebhookHandler({ secret, onEvent })`. Unsigned, stale, or oversized requests answer 401/413 without calling `onEvent`; a throwing `onEvent` answers 500 so the sender retries with backoff. | [Advanced](/docs/frameworks/nextjs/advanced) | | `matchPath(pattern, pathname)` | Glob over URL paths: `*` matches within one segment, `**` across segments. | [Next.js](/docs/frameworks/nextjs) | | `const pathnameHeader` | The request header middleware forwards so server components know the path being rendered. | [Next.js](/docs/frameworks/nextjs) | | `safeRedirectPath(value, fallback?)` | A same-origin path safe to redirect to after sign-in, or `fallback`. Rejects absolute URLs, protocol-relative `//host`, backslash tricks, and control characters, so a `?next=` parameter cannot become an open redirect. | [Middleware](/docs/frameworks/nextjs/middleware) | | `sessionCookieName(secure)` | The cookie name the server issues: host-prefixed on HTTPS, plain on loopback development. | [Next.js](/docs/frameworks/nextjs) | | `verifyAssertionToken(token, options)` | Verifies an assertion issued by `assertions.issue` with Web Crypto, so middleware and edge handlers can trust it without a database. Same rules as the server's `verifyAssertion`: HS256 only, audience, optional issuer, and time. | [Advanced](/docs/frameworks/nextjs/advanced) | | `verifyWebhook(input)` | Checks an `X-Better-IAM-Signature` header (`v1=`) with Web Crypto. Equivalent to the server's `verifyWebhookSignature`, for edge runtimes. | [Next.js](/docs/frameworks/nextjs) | | `withAssertion(options, handler)` | Route handler guard for services that receive assertions from a Better IAM application: the bearer token is verified offline and its claims passed to the handler. Failures answer 401 with the server's error envelope. | [Advanced](/docs/frameworks/nextjs/advanced) | ## @better-iam/nuxt [#better-iamnuxt] Nuxt module and h3 helpers. | Export | What it does | Explained in | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | | `export default` | The Nuxt module. Add `'@better-iam/nuxt'` to `modules` in `nuxt.config.ts`: it mounts the IAM API in Nitro, installs the Vue bindings with the session loaded during server rendering, and guards pages. | [Nuxt](/docs/frameworks/nuxt) | ### @better-iam/nuxt/h3 [#better-iamnuxth3] | Export | What it does | Explained in | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | | `createIamH3(source, options?)` | Server helpers for h3 and Nitro (Nuxt server routes, standalone Nitro, or h3 apps): read the session from the request cookies, enforce before handling, batch advisory decisions, and mount the IAM handler. Sessions are memoized per event. | [Nuxt](/docs/frameworks/nuxt) | | `eventHeaders(event)` | The request headers of an h3 v1 or v2 event. | [Nuxt](/docs/frameworks/nuxt) | | `eventRequest(event)` | A Web request for an h3 v1 or v2 event; the Node fallback buffers the body, so call it before anything reads it. | [Nuxt](/docs/frameworks/nuxt) | | `class IamH3Error` | An error h3 understands in both majors (`statusCode` for v1, `status` for v2); `data.code` carries the IAM code. | [Nuxt](/docs/frameworks/nuxt) | | `isAuthenticationError(error)` | True for the errors `getSession` raises when there is no usable session. | [Nuxt](/docs/frameworks/nuxt) | ## @better-iam/oauth [#better-iamoauth] OAuth/OIDC authorization server, resource-server helpers, and Shared Signals. | Export | What it does | Explained in | | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | `createAccessTokenVerifier(options)` | Offline verification of JWT access tokens issued for a resource server (RFC 9068), including DPoP proof of possession (RFC 9449) with in-memory proof replay detection. Opaque tokens need the provider's introspection. | [Resource servers and tokens](/docs/federation/oauth-resource-servers) | | `createOAuthLogin(config)` | Creates the "sign in with Google, GitHub, Microsoft, or your company's provider" flows: it sends people to an OAuth or OpenID Connect provider and signs them in when they come back. Attach it with `iam.useProtocol(...)`. | [OAuth and OIDC sign-in](/docs/federation/oauth-sign-in) | | `createOAuthProvider(config)` | Turns Better IAM into an OAuth 2.0 and OpenID Connect provider, so your other applications and MCP servers can sign people in with their account here and receive access tokens. | [OAuth/OIDC provider](/docs/federation/oauth-provider) | | `createProtectedResourceHandler(options)` | Serves the metadata document at its well-known path (GET, HEAD, and CORS preflight, readable from any origin); returns undefined for every other request so it can sit in front of the API's own routing. | [Dynamic registration and MCP](/docs/federation/mcp-authorization) | | `createProviderAdapter(store, encodedKey, validateSession?, registerClient?)` | Provider persistence with encrypted payloads and hashed token identifiers. Every artifact is bound to the tenant of its client and account, and every user grant is re-validated against its IAM session on each use. | | | `createResourceGuard(options)` | Everything an API (such as an MCP server) needs in front of its routes: it serves the RFC 9728 metadata document, verifies bearer or DPoP access tokens, and answers failures with 401/403 and a `WWW-Authenticate` challenge that names the metadata URL, so OAuth clients can discover the authorization server and try again. | [Dynamic registration and MCP](/docs/federation/mcp-authorization) | | `createSharedSignalsTransmitter(config)` | An OpenID Shared Signals Framework transmitter: turns IAM audit events (sessions revoked, credentials changed, identifiers changed, accounts disabled or deleted) into signed Security Event Tokens (RFC 8417) with CAEP and RISC event types, and pushes them to each tenant's receivers (RFC 8935) with retries. | [Shared Signals](/docs/federation/shared-signals) | | `protectedResourceMetadata(options)` | The metadata document (RFC 9728 §2) clients fetch to find the authorization server for an API. | [Dynamic registration and MCP](/docs/federation/mcp-authorization) | | `protectedResourceMetadataUrl(resource)` | Where the metadata lives: `/.well-known/oauth-protected-resource` inserted before the resource's path. | [Dynamic registration and MCP](/docs/federation/mcp-authorization) | | `const sharedSignalEvents` | Security event types the transmitter emits (CAEP and RISC). | [Shared Signals](/docs/federation/shared-signals) | ## @better-iam/projects [#better-iamprojects] Reference tenant-scoped Projects plugin. | Export | What it does | Explained in | | ------------------------ | --------------------------------------------------------- | --------------------------------------------------- | | `createProjectsPlugin()` | Reference plugin providing tenant-scoped project records. | [Adapters and plugins](/docs/operations/extensions) | ## @better-iam/react [#better-iamreact] React provider, hooks, and permission-gated components. | Export | What it does | Explained in | | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `Can(props)` | Renders children only when the advisory decision allows the action; `fallback` otherwise and `loading` meanwhile. | [React](/docs/frameworks/react) | | `createSessionStore(client, options?)` | Framework-agnostic session state. The React and Vue bindings subscribe to it; it can also drive other frameworks or plain scripts. Re-exported from `@better-iam/client`. | [React](/docs/frameworks/react) | | `IamProvider(props)` | Holds the typed client and one session store for the tree below. Create the client once, outside render. | [React](/docs/frameworks/react) | | `isUnauthenticated(error)` | True when an error means the caller is not signed in any more or must confirm who they are (the server answered 401 or 403). Anything else is a network or server failure, worth a retry rather than a sign-in page. Re-exported from `@better-iam/client`. | [React](/docs/frameworks/react) | | `useAccessible(options)` | The registered resources of a managed type the signed-in principal may act on; refetched when the input or identity changes. | [Batches and reverse queries](/docs/guides/authorization/queries) | | `useAccessPaths(options)` | For an action the signed-in person may be denied: whether they are allowed and, if not, the self-service paths (step up to MFA, accept terms, activate an eligible role, request a package) that would allow them. | [Access paths](/docs/guides/governance/access-paths) | | `useAgentCatalog(options)` | The agents the signed-in person may delegate to, with their purpose, model and sponsor (`agents.catalog`). | [AI agents](/docs/guides/ai-agents) | | `useAgreements(options)` | The signed-in person's terms of use and a way to accept them; policies can hold back access until they do. | [React](/docs/frameworks/react) | | `useAuthorize(options)` | Batched advisory decisions for rendering menus and buttons. Re-evaluated when the checks or the signed-in identity change; the server still enforces every operation. | [Batches and reverse queries](/docs/guides/authorization/queries) | | `useConfirmations(options)` | The actions AI agents acting for the signed-in person asked them to confirm (delegations with `confirm`), to answer from a notification or an inbox. An approval opens exactly that action on that resource for a few minutes. | [AI agents](/docs/guides/ai-agents) | | `useDelegations(options)` | The AI agents acting (or asking to act) for the signed-in person, and the actions to manage them. Granting and approving need a recent sign-in (`RECENT_AUTH_REQUIRED` otherwise). Agents never get more than the person has. | [AI agents](/docs/guides/ai-agents) | | `useFeatureFlag(options)` | Whether one feature flag is on for the tenant; `value` is `false` until it has loaded. | [Feature flags](/docs/guides/feature-flags) | | `useFeatureFlags(options)` | The tenant's feature flags for the signed-in session, to show or hide UI. `keys` limits the request to those flags. Hiding UI is not enforcement: gate the server side with `iam.features` or a `tenant.features` condition. | [Feature flags](/docs/guides/feature-flags) | | `useIamClient()` | The client passed to the provider, typed as the caller declares it. | [React](/docs/frameworks/react) | | `useModels(options)` | The AI models the signed-in caller may use now (`inference.listMine`), for a model picker. | [AI agents](/docs/guides/ai-agents) | | `useMySpend(options)` | The signed-in person's own spend (`billing.mySpend`): what their usage and their agents' cost this month (or `period`), grouped by `meter` (default), `day`, `agent`, `tenant` or `tag:{name}`, with a projection and their budgets. | [Billing and spend](/docs/guides/billing) | | `useSession()` | The current session snapshot plus refresh, sign-out, and manual replacement (after a sign-in response). | [React](/docs/frameworks/react) | | `useSpendCheck(options)` | Whether the signed-in caller's usage (of `meter`, when given) is within every enforced spend budget that covers it (`billing.check`), to warn before a costly action. `allowed` is `true` until the answer arrives; the server still refuses usage recorded with `enforceBudgets`. | [Billing and spend](/docs/guides/billing) | | `useTeams(options)` | The signed-in person's teams with self-service joining and leaving (`teams.listMine`, `requestToJoin`, `leave`). | | ## @better-iam/react-router [#better-iamreact-router] React Router (framework mode) middleware, guarded loaders, and actions. | Export | What it does | Explained in | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------- | | `checkRequestOrigin(request, trustedOrigins?)` | The CSRF boundary for application routes: a state-changing request authenticated by the session cookie (no `Authorization` header) must come from this application's origin, the IAM origin, or a trusted origin. Re-exported from `@better-iam/middleware`. | [Frameworks](/docs/frameworks) | | `checkStepUp(session, requirement, now?)` | Checks a session against a step-up requirement; null when it qualifies. Accepts the `getSession` result or its inner session record. Impersonated sessions always fail a recency requirement, as the server's own check does. Re-exported from `@better-iam/middleware`. | [Frameworks](/docs/frameworks) | | `createIamRouter(source, options?)` | React Router (framework mode, v7.9+ / v8) integration. `middleware` goes on the root route and gives every loader and action `iamRouter.helpers(args)`; cookies the in-process client receives are added to the response. `api` is the loader and action of an `api/iam/*` resource route. | [React Router](/docs/frameworks/react-router) | | `isAuthenticationError(error)` | True for the errors `getSession` raises when there is no usable session. Re-exported from `@better-iam/middleware`. | [React Router](/docs/frameworks/react-router) | | `safeRedirectPath(value, fallback?)` | A same-site relative path safe to redirect to, or `fallback`. Refuses schemes, `//host`, and backslash tricks. Re-exported from `@better-iam/middleware`. | [React Router](/docs/frameworks/react-router) | ## @better-iam/saml [#better-iamsaml] SAML 2.0 service provider with tenant-managed connections. | Export | What it does | Explained in | | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | | `certificateInfo(pem)` | Reads an identity provider's PEM certificate and returns its SHA-256 fingerprint, subject, and validity dates, so an administration screen can show which certificate is configured and when it expires. | | | `createSamlCache(store, tenantId, connectionId, expectedRequestId?)` | Remembers which SAML responses were already used, in the IAM database, so a captured response cannot be replayed. The SAML service creates it for you. | | | `createSamlService(config)` | Creates the SAML service provider: the metadata, sign-in, and assertion endpoints that let organizations sign in with their own identity provider. Attach it with `iam.useProtocol(...)`; see [SAML](/docs/federation/saml). | [SAML](/docs/federation/saml) | | `normalizeCertificate(value)` | Normalizes a PEM or bare base64 DER certificate to PEM, rejecting anything that is not a parseable X.509 certificate. | | | `parseIdpMetadata(xml)` | Reads entity ID, redirect-binding SSO/SLO endpoints, and signing certificates from IdP metadata. The document must describe exactly one identity provider; DTDs and entities are refused. | [SAML](/docs/federation/saml) | | `validateSamlEnvelope(xml, callbackUrl, expectedRequestId, encryptedRequired?)` | Supplements Node-SAML's cryptographic checks with exact response destination checks. `expectedRequestId: null` validates an IdP-initiated response, which must not answer any request. | | ## @better-iam/scim [#better-iamscim] SCIM 2.0 inbound provisioning and outbound provisioning to applications. | Export | What it does | Explained in | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | | `createScimProvisioner(config)` | Outbound SCIM 2.0 provisioning: keeps downstream applications' user directories in step with a tenant's members. | [SCIM outbound](/docs/federation/scim-outbound) | | `createScimService(config)` | SCIM 2.0 provisioning server: connection-scoped bearer tokens, Users and Groups with RFC 7644 filtering, sorting, attribute projection, `/.search`, PATCH value paths, `/Bulk`, ETags and pagination, and explicit administrator-controlled group-to-role mappings. | [Federation](/docs/federation) | | `parseScimFilter(filter)` | Parses a filter into a predicate over rendered SCIM resources; an absent filter matches everything. | | ## @better-iam/server [#better-iamserver] The `betterIam()` factory: tenants, identities, authorization, governance, HTTP API. | Export | What it does | Explained in | | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `actsInOwnRight(session)` | Tells whether a session acts in its identity's own right (a signed-in person or an API key) rather than as a temporary credential. Plugins and custom routes use it to refuse self-service actions, such as accepting terms, from role sessions and session tokens, as the built-in APIs do. | | | `const agentAttestationUri` | The URI of the attestation extension IAM adds to `capabilities.extensions` of every card it signs. | | | `amountDue(statement)` | What is still owed on an invoice: its total less payments and credit notes (never below 0). | | | `amountPaid(statement)` | Payments received on an invoice; invoices marked paid before payments were recorded count as fully paid. | | | `assertionKey(secret)` | Derives, from the deployment secret, the shared key that signs and verifies assertions (`iam.assertionKey()` returns the current one). Services that receive assertions verify them with it, so keep it as secret as the secret itself. | [Architecture overview](/docs/guides/concepts) | | `auditEventHash(event)` | The hash of an event: SHA-256 over the canonical JSON of every field except `hash` itself. Re-exported from `@better-iam/core`. | | | `const authenticatedAuthMethods` | The names of the `auth` methods the HTTP handler serves only to a signed-in caller, such as `signOut` and `listSessions`. | | | `betterIam(options)` | Creates your Better IAM instance from its options: the database, the deployment secret, the public URL, and the rest. The instance carries the typed server API (`iam.api`), the HTTP handler, and the authorization helpers. Create it once and import it wherever server code needs identity or access. | [Deployment](/docs/operations/deployment) | | `billingPeriod(value, name?)` | A billing period `YYYY-MM`. | | | `budgetWindow(period, now)` | The current window of a period (UTC), as \[start, end). | | | `canonicalJson(value)` | Deterministic JSON: object keys sorted recursively, `undefined` properties omitted, arrays kept in order. Re-exported from `@better-iam/core`. | [Audit chain](/docs/guides/events/audit-chain) | | `configOptions(config, context?)` | The options a `defineConfig` value stands for: a factory is called with `context` (default: this process's environment and working directory), plain options are returned as they are. | | | `createInferenceGateway(runtime, options?)` | Builds the inference gateway, an HTTP handler that lets any Better IAM credential call AI models through the Anthropic Messages and OpenAI Chat Completions APIs without holding a provider key. | | | `createJsonlAuditArchive(options)` | A file sink for `auditArchive`: one JSON Lines file per batch at `{directory}/{tenantId}/{fromSequence}-{toSequence}.jsonl` (sequences zero-padded, so files sort in chain order). | [Scheduled jobs](/docs/operations/jobs) | | `createMetrics(options?)` | Creates a standalone Prometheus-style metrics collector that you feed from your own `onSpan` hook. Most deployments use the one built from the `metrics` option instead; see [observability](/docs/operations/observability). | [Observability](/docs/operations/observability) | | `createSessionTokenVerifier(options)` | Verifies session JWTs issued by Better IAM in your other services, offline, against the published keys (`GET {basePath}/.well-known/jwks.json` or `iam.sessionTokens.jwks()`). | [Temporary access](/docs/guides/authorization/temporary-access) | | `dayOf(at, timeZone)` | The local day `YYYY-MM-DD` an instant falls on. | | | `defineConfig(config)` | Types a deployment configuration for `better-iam.config.{mjs,ts}`: options, or a factory of them that receives `{ command, env, cwd }` from the CLI. It returns its argument unchanged; the value is autocompletion and checking. | | | `defineTenantConfig(config)` | Types a tenant's configuration as code (roles, policies, groups, bindings, packages, invariants, agreements) for `better-iam config-plan\|config-apply --input tenant.config.ts`, or a factory that computes it per tenant and environment. | | | `const delegationTokenLimits` | Lifetimes (seconds) and the longest chain of actors a delegation token carries. Re-exported from `@better-iam/core`. | | | `const delegationTokenType` | The JWT `typ` header of delegation tokens: verifiers refuse any other. Re-exported from `@better-iam/core`. | | | `departmentHeads(tx, tenantId, departmentId, options?)` | The heads of a department (identity IDs): its own head and, with `includeAncestors`, the heads of the departments above it, nearest first and without duplicates. | | | `departmentOf(tx, tenantId, identityId)` | The ID of the department a person belongs to, if any. | | | `departmentPath(tx, tenantId, departmentId)` | Department IDs from the top of the tree down to `departmentId` itself (for roll-ups); empty when unknown. | | | `class IamError` | The same `IamError` class as `@better-iam/core`, re-exported so server code needs one import. Check `error.code` to handle a specific refusal. Re-exported from `@better-iam/core`. | [Adapters and plugins](/docs/operations/extensions) | | `const inferenceAction` | The action that calling an AI model needs, `inference:invoke`, which the `inference` option adds to the catalog. Policies grant it on `model/{name}`; use the constant in checks and policies you build in code so they match what the inference gateway and `inference.check` decide. | | | `const inferenceResourceType` | The resource type of AI models, `model`, which the `inference` option adds to the catalog. A model published with `inference.createModel` is the resource `model/{name}`, with its tier, family, provider, and prices as attributes for conditions. | | | `const inferenceToolAction` | The action decided on each `model-tool/{kind}` a request asks for, for models whose `providerTools` is `policy`. | | | `const inferenceToolResourceType` | Tools the provider runs itself (web search, code execution, remote MCP servers, ...), as resources of this type named by kind (`model-tool/web_search`, `model-tool/mcp:{host}`), checked with `inference:use-tool` for models whose `providerTools` is `policy`. | | | `isTeamMaintainer(tx, tenantId, teamId, identityId, options?)` | Whether a person maintains a team: a live maintainer membership of the team itself or, with `includeAncestors`, of any team above it (a parent team's maintainers manage its child teams too). | | | `lintPolicy(document, context?)` | Lints a policy document: validates it as storage would, then reports statements that grant more than intended, conditions that can never match or silently fail open, and statements that are shadowed or duplicated. The context tells the linter which identity attributes, resource attributes, and application keys exist. | [Policy documents](/docs/guides/authorization/policies) | | `looksLikeJwt(value)` | Tells a session JWT apart from an opaque token by its shape (three dot-separated segments), so code that accepts both can route each to the right check. It proves nothing about validity. | | | `machineIdentity(identity)` | Machine accounts: service accounts and agents. They hold API keys and never sign in. | | | `nextWatermark(current, before, now)` | Computes the next "revoke sessions issued before" time for a role, trust, or OIDC provider: it only ever moves forward and never into the future. Custom revocation tools use it to match `roles.revokeSessions`. | | | `periodBounds(period, timeZone)` | The first instant of a period and of the next one. | | | `periodOf(at, timeZone)` | The billing period an instant falls in. | | | `priceBreakdown(spec, quantity)` | How a quantity is priced, tier by tier, for invoice sub-lines: the tiers used, the free units, and what the tiers came to before the price's minimum or maximum. | | | `priceQuantity(spec, quantity)` | The amount, in micros, a period's quantity costs under a price: its tiers, then its minimum and maximum. | | | `priceSpec(value)` | Validates a price given in currency units (`unitAmount`, `tiers[].unitAmount` / `flatAmount`, `packageAmount`) and returns it in micros. Tiers ascend by `upTo`, and the last one has `upTo: null`. | | | `primaryTeamOf(tx, tenantId, identityId, at?)` | The person's oldest live direct team membership, for callers that attribute to one team only. | | | `providerToolsOf(path, body)` | The tools a request asks the provider to run (or that the provider defines for the model to drive): Anthropic server and Anthropic-defined tools (`tools[].type` other than `custom`) and remote MCP servers (`mcp_servers`); OpenAI Responses built-in tools (`tools[].type` other than `function` and `custom`, MCP servers as `mcp:{host}` or `mcp:{connector_id}`), and a stored prompt (`prompt`, which may bring tools of its own); Chat Completions web search (`web_search_options`). | | | `const publicApiMethods` | The few API methods the HTTP handler serves without a credential, such as accepting an invitation (the emailed token is the proof) or looking up a tenant by its alias. | | | `const publicAuthMethods` | The names of the `auth` methods the HTTP handler serves without a session, such as `signIn` and `resetPassword`. Exported so tools and tests can see the route table the handler enforces. | | | `publicOidcProvider(provider)` | Turns a stored OIDC provider into the record the API returns, an explicit list of its public settings. Use it when you read providers straight from storage. | | | `publicTrust(trust)` | Turns a stored trust into the record the API returns: every setting a reviewer needs, with `requiresExternalId` in place of the stored external ID hash. Use it when you read trusts straight from storage, for example in a custom export, so the hash never leaves the server. | | | `readDelegationTokenClaims(claims, expected)` | Checks the claims of a (signature-verified) delegation token against what the verifier expects: well formed, from an accepted issuer, for exactly this audience (and tenant), current within the clock tolerance, and issued for at most `delegationTokenLimits.maxSeconds`. Re-exported from `@better-iam/core`. | | | `renderInvoiceHtml(statement, options?)` | The invoice as a standalone HTML page (print it or save it as PDF): issuer, bill-to, number and dates, lines with tier sub-lines and service periods, discounts, commitment, credit, tax, payments, credit notes, and amount due. | | | `revokedByWatermark(createdAt, ...watermarks)` | Tells whether a session created at a given time falls under any of the given "revoke sessions issued before" times, the check IAM applies to role sessions on every use. | | | `rolloutBucket(key, tenantId)` | The rollout position (0 to 99.99) of a tenant branch for a flag: stable per key and tenant, so raising the percentage only ever adds tenants, and different flags spread over different tenants. | [Feature flags](/docs/guides/feature-flags) | | `const routeGroups` | The API groups the HTTP handler exposes under `{basePath}/{group}/{method}`. Groups not in this set are callable only from server code. | | | `const SESSION_TOKEN_ALGORITHMS` | The two signature algorithms session JWTs may use, EdDSA and ES256. Signing keys and verifiers accept nothing else. | | | `const SESSION_TOKEN_TYPE` | The `typ` header every Better IAM session JWT carries, `biam-session+jwt`, which keeps session tokens from being confused with assertions, OAuth access tokens, or tokens from other issuers. | | | `class SessionTokenError` | The error a session-token verifier throws for any token it rejects. `reason` says why, for logs and metrics; answer the caller with a plain 401. | | | `shiftPeriod(period, months)` | A period `months` after (or before, when negative) another. | | | `teamMaintainers(tx, tenantId, teamId, at?)` | The live direct maintainers of a team (identity IDs, oldest first). | | | `teamsOf(tx, tenantId, identityId, options?)` | The teams a person belongs to at `at` (default now): their live direct memberships (oldest first) and, with `includeAncestors`, the parent teams those memberships make them part of. Team IDs, never backing group IDs. | | | `const temporarySessionKinds` | The session kinds that are temporary credentials, `role`, `session-token`, and `delegated` (an AI agent acting for a person): derived from a source, bounded by it, and never allowed to pass recent-authentication or self-service checks. | | | `usageCost(model, usage)` | Cost of a call in micro-dollars: tokens × dollars per million tokens (cached input at its own price when set). | | | `usageFrom(format, usage)` | Token counts from a provider's usage object (Anthropic, OpenAI Chat Completions / Embeddings, or OpenAI Responses); undefined when there is none. Cached input tokens are counted apart from other input. | | | `validateTenantConfig(value)` | Validates the shape of a desired configuration; references are checked against the tenant during planning. | | | `verifyAssertion(token, options)` | Verifies an assertion against the derived key, audience, optional issuer, and time; returns its claims. `key` may list several keys (`iam.assertionKeys()`) while a deployment secret rotates. | [Operations recipes](/docs/guides/recipes/operations) | | `verifyAuditChain(events, options?)` | Verifies a run of events from one tenant: contiguous sequences, each `previousHash` equal to the previous event's hash (or `previousHash` of the first event when the run starts mid-chain), and every hash recomputable. Events are sorted by sequence first, so exports and database reads can be passed as they come. Re-exported from `@better-iam/core`. | [Audit chain](/docs/guides/events/audit-chain) | | `verifyWebhookSignature(input)` | Verifies a webhook signature produced by Better IAM. `signature` is the X-Better-IAM-Signature header value. | [Webhooks](/docs/guides/events/webhooks) | | `webTrustTagClaims(value)` | Checks a web-identity trust's `tagClaims` mapping (session tag keys to `token.{claim}` names, at most 10) and returns it cleaned, the same way `trust.create` and `trust.update` do, so tools that prepare trusts ahead of time fail early. | | | `class WrongRegionError` | The organization is served by another region's deployment. `location` is its sign-in URL there (when one can be built), so a sign-in page can redirect instead of showing an error. Answered with HTTP 421 Misdirected Request. | | ### @better-iam/server/assertions [#better-iamserverassertions] | Export | What it does | Explained in | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | | `assertionKey(secret)` | The verification key for a deployment secret: SHA-256 of a purpose-bound derivation, as hex. | [Architecture overview](/docs/guides/concepts) | | `createAssertionsApi(ctx)` | Builds the `iam.api.assertions` group from the server's internal context. `betterIam()` calls it for you; applications use `iam.api.assertions`, and the other exports of this entry point verify assertions. | | | `verifyAssertion(token, options)` | Verifies an assertion against the derived key, audience, optional issuer, and time; returns its claims. `key` may list several keys (`iam.assertionKeys()`) while a deployment secret rotates. | [Operations recipes](/docs/guides/recipes/operations) | ### @better-iam/server/session-tokens [#better-iamserversession-tokens] | Export | What it does | Explained in | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `createSessionTokenVerifier(options)` | Verifies Better IAM session JWTs in downstream services without contacting IAM. This entry point imports nothing from Node, so it runs in edge middleware, workers, Bun, and Deno as well as Node; point `jwks` at the IAM JWKS route or pass the key set, and call `verifyRequest(request)` from any framework. | [Temporary access](/docs/guides/authorization/temporary-access) | | `looksLikeJwt(value)` | True for a compact JWS shape (three base64url segments); says nothing about validity. | | | `const MAX_SESSION_TOKEN_LENGTH` | The longest session JWT Better IAM issues or accepts, 4096 characters, so services can refuse oversized headers before verifying them. | | | `const SESSION_TOKEN_ALGORITHMS` | The only signature algorithms IAM issues or accepts for session JWTs. | | | `const SESSION_TOKEN_TYPE` | The `typ` header of every IAM session JWT; other token classes (assertions, OAuth `at+jwt`) never carry it. | | | `class SessionTokenError` | Every verification failure. `reason` is for logs and metrics; clients should only see the 401. | | ## @better-iam/svelte [#better-iamsvelte] Svelte stores and SvelteKit hooks, guards, and actions. | Export | What it does | Explained in | | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | | `createIam(options)` | One typed client, one session store, and advisory authorization stores for a Svelte 4 or 5 app. Create it once (typically in the root `+layout.svelte`) and share it with `setIamContext` / `getIamContext`. | [SvelteKit](/docs/frameworks/sveltekit) | | `createSessionStore(client, options?)` | Framework-agnostic session state. The React and Vue bindings subscribe to it; it can also drive other frameworks or plain scripts. Re-exported from `@better-iam/client`. | [SvelteKit](/docs/frameworks/sveltekit) | | `getIamContext()` | The `Iam` a parent shared with `setIamContext`. | [SvelteKit](/docs/frameworks/sveltekit) | | `isUnauthenticated(error)` | True when an error means the caller is not signed in any more or must confirm who they are (the server answered 401 or 403). Anything else is a network or server failure, worth a retry rather than a sign-in page. Re-exported from `@better-iam/client`. | [SvelteKit](/docs/frameworks/sveltekit) | | `setIamContext(iam)` | Shares an `Iam` with descendant components; call during component initialisation. | [SvelteKit](/docs/frameworks/sveltekit) | ### @better-iam/svelte/kit [#better-iamsveltekit] | Export | What it does | Explained in | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- | | `checkStepUp(session, requirement, now?)` | Checks a session against a step-up requirement; null when it qualifies. Accepts the `getSession` result or its inner session record. Impersonated sessions always fail a recency requirement, as the server's own check does. Re-exported from `@better-iam/middleware`. | [SvelteKit](/docs/frameworks/sveltekit) | | `createIamKit(source, options?)` | SvelteKit integration: `handle` serves the IAM HTTP API, enforces `protect` rules, and gives every request `event.locals.iam`; `guard` and `action` wrap server loads and form actions. | [SvelteKit](/docs/frameworks/sveltekit) | | `isAuthenticationError(error)` | True for the errors `getSession` raises when there is no usable session. Re-exported from `@better-iam/middleware`. | [SvelteKit](/docs/frameworks/sveltekit) | | `isKitControlError(error)` | True for SvelteKit's own control-flow throws (`redirect()`, `error()`), which must propagate untouched. | [SvelteKit](/docs/frameworks/sveltekit) | | `parseSetCookie(header)` | Parses one `Set-Cookie` header into the name, decoded value, and options `event.cookies.set` accepts. | [SvelteKit](/docs/frameworks/sveltekit) | | `safeRedirectPath(value, fallback?)` | A same-site relative path safe to redirect to, or `fallback`. Refuses schemes, `//host`, and backslash tricks. Re-exported from `@better-iam/middleware`. | [SvelteKit](/docs/frameworks/sveltekit) | ## @better-iam/vue [#better-iamvue] Vue plugin, composables, and the `IamCan` component. | Export | What it does | Explained in | | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `createHydration(state?)` | A plain object hydration store; serialize `state` into the page and pass the parsed object back on the client. | [Vue](/docs/frameworks/vue) | | `createIam(options)` | The Vue plugin: one typed client and one session store for the app. `app.use(createIam({ client }))`. | [Vue](/docs/frameworks/vue) | | `createSessionStore(client, options?)` | Framework-agnostic session state. The React and Vue bindings subscribe to it; it can also drive other frameworks or plain scripts. Re-exported from `@better-iam/client`. | [Vue](/docs/frameworks/vue) | | `const IamCan` | Renders the default slot only when the advisory decision allows the action, the `fallback` slot otherwise, and the `loading` slot meanwhile. ``. | [Vue](/docs/frameworks/vue) | | `isUnauthenticated(error)` | True when an error means the caller is not signed in any more or must confirm who they are (the server answered 401 or 403). Anything else is a network or server failure, worth a retry rather than a sign-in page. Re-exported from `@better-iam/client`. | [Vue](/docs/frameworks/vue) | | `useAccessible(input)` | The registered resources of a managed type the signed-in principal may act on; refetched when the input or identity changes. | [Batches and reverse queries](/docs/guides/authorization/queries) | | `useAccessPaths(input)` | Whether the signed-in person may perform an action and, if not, the self-service paths that would allow it. | [Vue](/docs/frameworks/vue) | | `useAgreements(input)` | The signed-in person's terms of use and a way to accept them; policies can hold back access until they do. | [Vue](/docs/frameworks/vue) | | `useAuthorize(input)` | Batched advisory decisions for rendering menus and buttons. Accepts a ref or getter so the checks can follow reactive state; the server still enforces every operation. | [Batches and reverse queries](/docs/guides/authorization/queries) | | `useCan(input)` | One advisory decision as a boolean ref, false while loading or signed out. | [Batches and reverse queries](/docs/guides/authorization/queries) | | `useIamClient()` | The client passed to `createIam`, typed as the caller declares it. | [Nuxt](/docs/frameworks/nuxt) | | `useSession()` | Reactive session state plus refresh, sign-out, and manual replacement. | [Vue](/docs/frameworks/vue) | | `useTeams(input)` | The signed-in person's teams with self-service joining and leaving (`teams.listMine`, `requestToJoin`, `leave`). | | # Glossary (/docs/reference/glossary) > Plain-language definitions of the identity and access terms used throughout the Better IAM documentation. Identity and access management has its own vocabulary, and Better IAM uses it precisely. Each term below is defined in one or two sentences with a link to the page that explains it in depth. Throughout the guides, terms shown with a dotted underline open the same definition on hover. ## A [#a] ### Access package [#access-package] A named bundle of roles and group memberships that can be assigned in one step, requested by members, or granted automatically by a rule (birthright access). [Learn more](/docs/guides/privileged-access/access-packages) ### Access request [#access-request] A person asking for a role for a limited time, decided by a reviewer who must themselves be allowed to grant it. [Learn more](/docs/guides/authorization/temporary-access) ### Access window [#access-window] A weekly schedule attached to a binding, such as weekdays 09:00 to 17:00, outside which the binding grants nothing. [Learn more](/docs/guides/authorization/temporary-access) ### Action [#action] A named operation that can be allowed or denied, written as `namespace:verb` such as `documents:write`. Built-in administration actions start with `iam:` (for example `iam:identities:create`). [Learn more](/docs/guides/authorization/catalog) ### Activation [#activation] A time-limited use of an eligible binding. It ends by itself, can be ended early, and is audited from request to expiry. [Learn more](/docs/guides/privileged-access/elevation) ### Advisory decision [#advisory-decision] A decision used to shape a user interface, such as hiding a button. It is never enforcement: the server must still check with `iam.require` right before the protected operation. [Learn more](/docs/guides/authorization/queries) ### Agreement [#agreement] A versioned terms-of-use text that members accept. Policies can require acceptance through `principal.agreements`, and a new version asks everyone to accept again. [Learn more](/docs/guides/governance/agreements) ### Assertion [#assertion] A short-lived signed token that tells a downstream service who is calling and with which roles, so services can trust the caller without sharing sessions or a database. [Learn more](/docs/reference/api/assertions) ### Audit chain [#audit-chain] The per-tenant audit log in which every event includes the SHA-256 hash of the previous one, so any edited, removed, or reordered event is detected by verification. [Learn more](/docs/guides/events/audit-chain) ## B [#b] ### Binding [#binding] The record that gives a role to an identity or a group within a tenant. Bindings can expire, start in the future, apply only in an access window, or be eligible (activated just in time). [Learn more](/docs/guides/authorization/roles) ### Birthright access [#birthright-access] Access everyone matching a rule should have automatically, such as "everyone in the engineering department gets the Engineering package". Rules on access packages assign it and remove it again when a person stops matching. [Learn more](/docs/guides/privileged-access/automatic-assignment) ### Boundary [#boundary] A policy that limits the maximum access something can have without granting any. Tenants, principals, sessions, and credentials can each carry one, and a request must be allowed by every boundary that applies. [Learn more](/docs/guides/authorization/policies) ## C [#c] ### Ceiling [#ceiling] An upper bound attached to a grant authority, a trust, or a credential. Whatever the role says, access through that path can never exceed the ceiling; like a boundary, it only takes access away. [Learn more](/docs/guides/authorization/roles) ### Certification campaign [#certification-campaign] A periodic review in which reviewers confirm or revoke each person's access, with evidence of use, so access that is no longer needed gets removed. [Learn more](/docs/guides/governance/certifications) ### Condition [#condition] An extra test inside a statement that compares a context value with expected values using an operator such as `StringEquals`, `IpAddress`, or `DateBefore`. A missing value never satisfies a condition. [Learn more](/docs/guides/authorization/conditions) ### Consent [#consent] A person's approval for an OAuth client application to act on their behalf with certain scopes. Consents are remembered, listed as connected apps, and can be revoked. [Learn more](/docs/federation/oauth-provider) ### Credential [#credential] What a caller presents to prove who it is: a session cookie, an `Authorization: Bearer` token, an API key, or an assumed-role session. Server calls pass it as the first argument, `{ headers }` or `{ token }`. [Learn more](/docs/reference/api) ### Custom hostname [#custom-hostname] A hostname an organization controls, such as login.acme.com, verified with a DNS TXT record so it works as the organization's sign-in address. A verified hostname belongs to exactly one organization. [Learn more](/docs/operations/deployment/hosts-and-regions#custom-hostnames) ## D [#d] ### Delivery outbox [#delivery-outbox] An encrypted, transactional queue of emails and SMS messages (invitations, codes, alerts). Messages are written in the same transaction as the change that caused them and delivered by a worker, so a rolled-back change never sends mail. [Learn more](/docs/operations/jobs) ### Deployment operation [#deployment-operation] A task that runs from your deploy scripts, a worker, or cron rather than from a web request, such as migrations, secret rotation, and the scheduled jobs. None of them are reachable over HTTP. [Learn more](/docs/operations/jobs) ### Doctor [#doctor] The `better-iam doctor` command and `iam.selfCheck()`: a report of configuration and storage problems such as a schema that is behind, a weak secret, a missing email transport, or jobs that are not running. [Learn more](/docs/operations/storage) ### DPoP [#dpop] Demonstrating Proof of Possession (RFC 9449): the client signs each request with a private key, and the access token is bound to that key, so a leaked token is useless without it. [Learn more](/docs/federation/oauth-resource-servers) ## E [#e] ### Eligible binding [#eligible-binding] A role binding that grants nothing until the person activates it for a bounded time, optionally with a justification, MFA, or an approver's decision. It replaces standing administrator access with just-in-time elevation. [Learn more](/docs/guides/privileged-access/elevation) ### Explicit deny [#explicit-deny] A matching `deny` statement. It overrides every allow, which makes deny statements the tool for exceptions such as "never delete without MFA". [Learn more](/docs/guides/authorization/policies) ## G [#g] ### Grant [#grant] An allow that comes from a role or policy bound to the principal. Grants form a union: one matching allow is enough, unless a deny or a boundary blocks it. [Learn more](/docs/guides/authorization) ### Grant authority [#grant-authority] Who may hand out a role. A binding records the authority it was created under, and only someone holding that authority (or a higher one) may change or revoke it, which stops people from granting access they could not grant directly. [Learn more](/docs/guides/authorization/roles) ### Group [#group] A set of identities that receives every role bound to it. Changing group membership is the usual way to grant or revoke access for teams. [Learn more](/docs/reference/api/groups) ## H [#h] ### Home region [#home-region] In a multi-region deployment, the region whose deployment serves an organization's sign-in: its own region, or its nearest ancestor's. Other regions answer WRONG\_REGION with its sign-in URL there. [Learn more](/docs/operations/deployment/hosts-and-regions#regions) ## I [#i] ### Identity [#identity] A person or service account inside one tenant. The same human who belongs to two organizations has two identities, which can be linked so they can switch between them. [Learn more](/docs/guides/concepts/tenants-and-identities) ### Impact preview [#impact-preview] A dry run of a change to a role, policy, or binding that shows who would gain or lose access, and which invariants would break, before you apply it. [Learn more](/docs/guides/governance/change-safety) ### Impersonation [#impersonation] An administrator viewing the product as a member ("view as") for support. It is audited, visible to policies, and never allows more than the administrator could do themselves. [Learn more](/docs/guides/authentication/impersonation) ### Invariant [#invariant] A guardrail that must stay true whatever roles and policies say, such as "contractors can never delete the payroll workspace". Invariants are checked on demand, in CI, and on a schedule. [Learn more](/docs/guides/governance/change-safety) ## O [#o] ### OpenID Connect [#openid-connect] The identity layer on top of OAuth 2.0 that lets people sign in with an external identity provider, and that Better IAM can also provide to your own applications. [Learn more](/docs/federation/oauth-sign-in) ### Owner [#owner] An identity marked as an owner of its tenant, usually the person who accepted the organization's first invitation. Owners hold the protected Owner role, and policies can test `principal.owner`. [Learn more](/docs/guides/concepts/tenants-and-identities) ## P [#p] ### Passkey [#passkey] A WebAuthn credential stored on a device or password manager that signs people in without a password and cannot be phished. It can also serve as the second factor. [Learn more](/docs/guides/authentication/passkeys) ### Permission catalog [#permission-catalog] The list of every action that exists: built-in `iam:*` actions, the actions of your declared resource types, and tenant-defined actions. Roles and policies can only refer to actions in the catalog. [Learn more](/docs/guides/authorization/catalog) ### Plugin [#plugin] An extension that adds resource types, context values, operation hooks, or endpoints to the same authorized, audited pipeline as built-in operations. [Learn more](/docs/operations/extensions) ### Policy [#policy] A versioned JSON document of statements that allow or deny actions on resources, optionally under conditions. Roles can be built from policies, and boundaries are policies too. [Learn more](/docs/guides/authorization/policies) ### Policy variable [#policy-variable] A placeholder like `${principal.id}` inside a resource pattern or condition value, replaced with the caller's actual value before matching. Substituted values always match literally, so they cannot widen a pattern. [Learn more](/docs/guides/authorization/policies) ### Principal [#principal] Whoever is making a request after the credential has been checked: the identity plus the session it is using. Policies read facts about it as `principal.*` context keys, such as `principal.id` or `principal.mfa`. [Learn more](/docs/guides/authorization) ## R [#r] ### Recent authentication [#recent-authentication] Proof that the person signed in or re-authenticated a few minutes ago. Sensitive operations such as changing an email or creating a webhook require it, so a stolen long-lived session cannot perform them. [Learn more](/docs/guides/authentication/sessions) ### Reconciliation [#reconciliation] The scheduled job that re-applies access-package rules: it gives packages to people who now match a rule and removes automatic assignments from people who no longer do. [Learn more](/docs/guides/privileged-access/automatic-assignment) ### Relationship [#relationship] A tuple that says a person or group has a named relation, such as `editor`, to one resource. Policies read them as `resource.relations`, which is how per-document sharing works (relationship-based access control, ReBAC). [Learn more](/docs/guides/authorization/relationships) ### Resource [#resource] The thing an action is performed on, identified as `type/id` (for example `document/doc_42`). Resources are either resolved by your application at decision time or registered with IAM as managed resources with owners and parents. [Learn more](/docs/guides/concepts/resources-and-catalog) ### Resource server [#resource-server] An API that accepts access tokens issued by Better IAM's OAuth provider and checks them itself, for example with `createAccessTokenVerifier`. [Learn more](/docs/federation/oauth-resource-servers) ### Resource type [#resource-type] A declared kind of resource with its actions, typed attributes, and relations. Types come from your configuration or are defined by a tenant at runtime. [Learn more](/docs/guides/authorization/catalog) ### Retention sweep [#retention-sweep] The scheduled `sweep` job that deletes expired sessions, devices, protocol artifacts, and old delivery records in short batches so the database does not grow without bound. [Learn more](/docs/operations/jobs) ### Reverse query [#reverse-query] Asking which resources a person may act on, instead of whether they may act on one. `listAccessible` powers list pages; `authorizeMany` answers up to 50 checks at once for a UI. [Learn more](/docs/guides/authorization/queries) ### Role [#role] A named bundle of permissions (or of policy documents) that can be given to people, such as "Editor". A role grants nothing until it is bound to someone. Roles can inherit other roles. [Learn more](/docs/guides/authorization/roles) ### Role assumption [#role-assumption] A platform-controlled way to act in another tenant with a specific role through a trust, producing a session that carries only that role's grants. [Learn more](/docs/reference/api/trust) ### Root tenant [#root-tenant] The tenant at the top of the tree, created once by `bootstrap`. Its administrators operate the platform itself: they create organizations, set plan limits, and recover access. The root administrator must enroll MFA before doing anything else. [Learn more](/docs/guides/concepts/tenants-and-identities) ## S [#s] ### SAML [#saml] An XML-based single sign-on standard common in enterprises. Better IAM acts as the service provider, so organizations can sign in through their own identity provider. [Learn more](/docs/federation/saml) ### SCIM [#scim] System for Cross-domain Identity Management (RFC 7643/7644): the standard API identity providers such as Okta and Entra ID use to create, update, and deactivate users and groups in your application automatically. [Learn more](/docs/federation/scim) ### Separation of duties [#separation-of-duties] A rule that one person must not hold two conflicting kinds of access, such as creating and approving payments. Grants that would break a rule fail with `SOD_CONFLICT`. [Learn more](/docs/guides/authorization/separation-of-duties) ### Service account [#service-account] An identity for a machine rather than a person (`kind: 'service'`). It authenticates with API keys, never signs in interactively, and can be scheduled to deactivate. [Learn more](/docs/reference/api/service-accounts) ### Session [#session] A signed-in period stored in your database. It records how the person authenticated (password, passkey, MFA), when, and from which device, and it can be revoked at any time, which takes effect on the next request. [Learn more](/docs/guides/authentication/sessions) ### Shared Signals [#shared-signals] The OpenID Shared Signals Framework: signed security events, such as a session being revoked (CAEP) or an account being disabled (RISC), pushed to other applications so they can react immediately. [Learn more](/docs/federation/shared-signals) ### Sign-in address [#sign-in-address] An organization's own address for signing in, like an AWS account's sign-in URL: a subdomain built from its alias (acme.signin.example.com) or a custom hostname it verified. Requests on it are pinned to that organization. [Learn more](/docs/operations/deployment/hosts-and-regions) ### Standing privilege [#standing-privilege] Powerful access that is always on, such as a permanent administrator role. It is convenient but risky, because a stolen account has that power all the time; eligible bindings replace it with elevation on demand. [Learn more](/docs/guides/privileged-access) ### Statement [#statement] One rule inside a policy: an effect (`allow` or `deny`), the actions and resource patterns it covers, and optional conditions. A statement matches when all three match the request. [Learn more](/docs/guides/authorization/policies) ### Step-up authentication [#step-up-authentication] Asking a signed-in person to authenticate again, or with MFA, right before a sensitive operation such as changing their email or deleting an account. [Learn more](/docs/guides/authentication/mfa) ### Storage adapter [#storage-adapter] The module that stores Better IAM's records in a database: PostgreSQL, SQLite, and libSQL are included, and the adapter contract lets you add others. [Learn more](/docs/operations/storage) ### Synthetic session [#synthetic-session] The simulated session that access reviews such as `whoCan` and `simulate` evaluate an identity in, without anyone signing in. It has no client address and uses MFA only when you ask for it. [Learn more](/docs/guides/authorization/reviews) ## T [#t] ### Tenant [#tenant] An account that people sign in to: your platform's root, an organization, or a project inside one. Tenants form a tree; each has its own identity directory, roles, policies, and audit chain, and nothing crosses tenant boundaries unless a trust or a linked account says so. [Learn more](/docs/guides/concepts/tenants-and-identities) ### Tenant access policy [#tenant-access-policy] Minimum rules for just-in-time elevation in one tenant, such as a maximum activation length or always requiring MFA, that individual bindings cannot weaken. [Learn more](/docs/guides/privileged-access/elevation) ### Trust [#trust] A root-created permission for identities of one tenant to assume a role in another tenant. The trust carries a ceiling that limits what the assumed role can do. [Learn more](/docs/reference/api/trust) ### Trusted device [#trusted-device] A browser or device remembered after a successful second factor ("remember this device"), so later sign-ins there skip MFA until the tenant's trusted-device window ends or the device is revoked. [Learn more](/docs/guides/authentication/mfa) ## W [#w] ### Webhook [#webhook] A signed HTTP callback that a tenant subscribes to receive audit events, with filters, retries, and redelivery. [Learn more](/docs/guides/events/webhooks) # Reference (/docs/reference) > Generated reference for the server API, package exports, the CLI, error codes, packages, and the changelog, extracted from the repository. Everything in this section is generated from the repository by `apps/docs/scripts/generate.mjs`, so it matches the code it documents: method names and signatures come from the built TypeScript declarations and a live `betterIam()` instance, error codes from every `IamError` thrown in `packages/*/src`, CLI usage from the CLI's own help text, and packages from the workspace manifests. - [Server API](/docs/reference/api): Every `iam.api` group and method with its HTTP route, credential requirement, and TypeScript signature. - [Package exports](/docs/reference/exports): Every function, hook, component, and class the packages export, what it does, and the guide that uses it. - [CLI](/docs/reference/cli): The `better-iam` command line: migrations, bootstrap, audits, configuration as code, and scheduled jobs. - [Error codes](/docs/reference/errors): Every stable `IamError` code grouped by HTTP status, with example messages. - [Packages](/docs/reference/packages): The publishable packages, their subpath exports, and dependencies. - [Changelog](/docs/reference/changelog): Every notable change, newest first. ## Machine-readable docs [#machine-readable-docs] The site serves its content for tools and language models as well as for people: | URL | Content | | --------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `/llms.txt` | An index of every page with its description | | `/llms-full.txt` | Every page as Markdown in one file | | `/docs/.md` | One page as Markdown (also served for `Accept: text/markdown`) | | `/api/search?query=…` | The search index behind the search dialog, as JSON | | `/api/mcp` | An MCP server with search, page, API-method, export, and error-code tools; see [Use the docs with AI](/docs/reference/ai) | | `/openapi.json` | The OpenAPI 3.1 description of the HTTP API | ## Regenerating [#regenerating] ```bash pnpm build # declarations the API catalog is read from pnpm --filter @better-iam/docs generate # data + generated pages (API, exports, errors, CLI, glossary) node apps/docs/scripts/check-content.mjs # validate every page and link ``` # Packages (/docs/reference/packages) > The publishable Better IAM packages with their subpath exports, runtime dependencies, and peer dependencies. The workspace publishes one package per concern, plus the umbrella `better-iam` package that depends on all of them and re-exports each as a subpath. Every package is ESM, ships TypeScript declarations, and requires Node.js 22.12 or newer. Pick individual packages when a service needs only part of the platform; see [Installation](/docs/guides/installation) for the subpath map. ## How the packages depend on each other [#how-the-packages-depend-on-each-other] Arrows point from a package to the workspace packages it depends on (generated from the manifests; the umbrella package, which depends on all of them, is omitted). Browser-side packages (`client`, `react`, `vue`, `svelte`) never depend on `server` or `auth`, so they never pull server code or native modules into a bundle. `@better-iam/core` has no dependencies and runs in any JavaScript runtime, which is why the [policy playground](/playground) can evaluate policies in your browser with the same engine the server uses. # Types (/docs/reference/types) > The core TypeScript types of Better IAM, what each one represents, and their exact fields, generated from the published declarations. You will meet a handful of types over and over: the policy document you write, the decision you get back, the request you pass to `iam.require`, and the tenant, identity, and session records the API returns. This page explains what each one represents and when you handle it, followed by its exact fields. > **Always in sync with the code.** The tables are generated from the packages' `.d.ts` files every time the site builds, so field names, optionality, and doc comments match the code you install. Select a type in a table to see its full definition. ## Policy documents [#policy-documents] A policy is a JSON document you store with `policies.create` or attach to a role. It holds up to 128 statements; each statement allows or denies some actions on some resource patterns, optionally only when its conditions hold. Better IAM validates every document with `validatePolicy` before storing it and again before evaluating it, so a malformed or hand-edited document can never change a decision. See [Policies](/docs/guides/authorization/policies) and try documents in the [playground](/playground). `PolicyStatement` is one rule. `effect` is `allow` or `deny`; `actions` and `resources` are glob patterns where `*` matches any run of characters and `?` exactly one; `conditions` maps an operator (such as `StringEquals`) to the context keys it tests. `sid` is an optional name that shows up in decision traces. ### Decisions [#decisions] A `Decision` is the answer to "may this principal perform this action on this resource?". `allowed` is the verdict, `reason` says why: the policy engine's `allowed`, `explicit-deny`, `no-grant`, or `boundary-deny`, or a platform reason decided before any policy is read, such as `TENANT_INACTIVE` (the tenant is suspended), `TENANT_MISMATCH` (the credential belongs to another tenant), `ROOT_OVERRIDE` (a platform root administrator), `IMPERSONATOR_DENIED` (a "view as" session asked for more than the administrator may do), or `CREDENTIAL_AUTHORITY_REVOKED`. `matched` lists the statements that matched. Server calls strip `matched` before returning decisions to callers so they do not leak policy contents. ## Authorization requests [#authorization-requests] `AuthorizationRequest` is what you pass to `iam.authorize` (which returns a `Decision`) and `iam.require` (which throws `ACCESS_DENIED` instead). It names the tenant, the action, and the resource, and carries the caller's credential: pass the incoming request's `headers` (which contain the session cookie or bearer token) or a raw `token`. Load the resource's identity from your own trusted storage, never from the request body. `CredentialInput` is the first argument of every `iam.api` method: who is calling. The same shape is embedded in `AuthorizationRequest`. ## Tenants [#tenants] A tenant is an account in the tenant tree: the platform root, an organization, or a project inside one. `type` must be allowed as a child of the parent's type by the `hierarchy` option, `slug` is the sign-in alias people type to find their organization, and `status` controls whether anyone can sign in (suspension is inherited by every descendant). The policy objects configure how people sign in and how elevated access works in that tenant. See [Tenants and identities](/docs/guides/concepts/tenants-and-identities). ### Authentication policy [#authentication-policy] `TenantAuthPolicy` lets one organization tighten sign-in without changing your deployment: require MFA (for everyone or only owners), restrict sign-in methods, shorten session lifetimes and idle timeouts, cap concurrent sessions, enforce password rules and history, limit sign-in to IP ranges, bind sessions to the network they started on, and control whether support staff may impersonate members. Set it with [`tenants.setAuthPolicy`](/docs/reference/api/tenants#setauthpolicy); the [tenant policy guide](/docs/guides/authentication/tenant-policy) explains every field. ### Access policy [#access-policy] `TenantAccessPolicy` sets minimum rules for just-in-time elevation in a tenant, which individual bindings cannot weaken: a maximum activation length and whether activations always need a justification, MFA, or an approval (and how long an approval stays usable). Set it with [`tenants.setAccessPolicy`](/docs/reference/api/tenants#setaccesspolicy). See [Just-in-time elevation](/docs/guides/privileged-access/elevation). ## Identities and sessions [#identities-and-sessions] API responses never include credential material: identities are returned as `PublicIdentity` and sessions as `SafeSession`, without password hashes or token hashes. ### PublicIdentity [#publicidentity] A person or service account in one tenant, as the API returns it. `kind` tells people (`user`) from machines (`service`); `owner` marks tenant owners; `attributes` holds the typed directory attributes you declared (department, title, and so on), which policies read as `principal.{name}`; `expiresAt` schedules automatic deactivation. ### SafeSession [#safesession] A signed-in session without its secret token. It records how and when the person authenticated (`method`, `mfa`), when it expires, the device it belongs to, and, for support sessions, who is impersonating. Use it to show "where you are signed in" lists and to decide whether to ask for step-up authentication. ### Sign-in results [#sign-in-results] `auth.signIn` and the other sign-in methods return a `SessionResult` when the person is fully signed in, or an `MfaRequired` challenge when a second factor is still needed. The challenge tells you which factors are available (an enrolled authenticator, an emailed code, a passkey) so your UI can offer the right one. See [Multi-factor authentication](/docs/guides/authentication/mfa). ## Audit events [#audit-events] An `AuditEvent` records one thing that happened in a tenant: an API call (allowed or denied), a failed sign-in, or an access-lifecycle change such as a role activation. It is what `audit.list` returns and what `iam.events.subscribe` handlers receive, and webhooks deliver the same fields with `action` renamed to `type`. `action` says what happened (`iam:roles:create`, `binding:activate`), `actorId` who did it, `resourceId` what it was done to, and `outcome` whether it was allowed. In an impersonation session, `impersonatorId` names the administrator while `actorId` stays the member. `sequence`, `previousHash`, and `hash` place the event in the tenant's tamper-evident audit chain. See [Events and audit](/docs/guides/events) for every event name. ### Stored records [#stored-records] The storage models, for adapter and plugin authors. Hash fields exist only here and are never returned by the API. #### Identity #### Session # Enterprise onboarding (/docs/federation/enterprise-onboarding) > Take one customer organization from "we use Okta or Entra ID" to SSO, directory provisioning, app provisioning, and end-to-end offboarding. When a company buys your product, its IT team usually asks for the same things: "our people sign in with our identity provider", "our directory creates and removes their accounts", and "when someone leaves, they lose access everywhere". Enterprise buyers often treat these as purchase requirements, because manual account management is slow and leaves former employees with access. This walkthrough takes one customer organization from "we use Okta or Entra ID" to fully managed access. Its people sign in with the company's identity provider (IdP), are created and removed by the company's directory, and flow on to the SaaS applications you connect for them. The same steps work from your own admin UI, the console, or a script. The [Federation overview](/docs/federation#the-protocols-in-plain-words) explains each protocol in plain words. **Who does what.** Your team sets up the protocol services once for your deployment. Each step below is then a small piece of work done together with the customer's IT administrator: they publish a DNS record, paste values into their IdP, and hand you metadata or tokens. Each step names who does which part. The examples assume a server instance `iam`, an organization (a tenant) `tenantId`, and a credential `credential` for an administrator of that organization: a cookie session or an API key with the listed permissions. `saml`, `login`, `scim`, and `provisioner` are the protocol services from [SAML](/docs/federation/saml), [OAuth and OIDC sign-in](/docs/federation/oauth-sign-in), [SCIM inbound](/docs/federation/scim), and [SCIM outbound](/docs/federation/scim-outbound), mounted with `iam.useProtocol`. `issuer` is the [OAuth/OIDC provider](/docs/federation/oauth-provider) and `signals` the [Shared Signals](/docs/federation/shared-signals) transmitter. ### Prove the email domain [#prove-the-email-domain] **Why:** a verified domain tells Better IAM that everyone with an `@acme.com` address belongs to Acme. That lets your sign-in page send people to the right organization and identity provider from their email address alone, and stops another organization from claiming Acme's domain. **Who:** Acme's administrator claims the domain in your admin UI, and whoever manages Acme's DNS publishes the record. Your team only builds the screen. ```ts const claimed = await iam.api.domains.add(credential, { tenantId, domain: 'acme.com' }); // Publish claimed.dnsRecord (a TXT record), then: await iam.api.domains.verify(credential, { tenantId, domainId: claimed.id }); ``` `domains.add` claims the domain and returns a TXT record to publish, named `_better-iam-challenge.acme.com` by default. `domains.verify` looks the record up in DNS and marks the domain verified. A verified domain belongs to exactly one organization, and shared mailbox providers such as `gmail.com` cannot be claimed. Your sign-in page can then route people by email. This lookup is often called **home-realm discovery**. [`domains.discover`](/docs/reference/api/domains#discover) takes `{ email }` and answers with the organization, its slug, the sign-in methods it accepts, and whether it requires MFA, so people never have to know a tenant ID or slug. Pass the email on as a sign-in hint in the next step so people do not type it twice. See the [`domains` API group](/docs/reference/api/domains). ### Connect the identity provider [#connect-the-identity-provider] **Why:** single sign-on (SSO) means people use the company account they already have, with the company's own password rules and MFA, and IT can cut access in one place. **Who:** for SAML, your team configures the service-provider key pair once, and Acme's IT administrator registers your app in their IdP and uploads its metadata in your admin UI. For OpenID Connect, Acme's IT administrator registers your app and sends you its client ID and secret, and your team adds the connection to the deployment configuration. **SAML:** For Okta, Entra ID, ADFS, Google Workspace, and other SAML IdPs. With one deployment-wide service-provider key pair configured (`serviceProvider` in `createSamlService`), organization administrators upload their IdP's metadata themselves: ```ts const sso = await saml.createConnection(credential, { tenantId, id: 'acme-okta', name: 'Acme Okta', metadataXml, trustedEmailDomains: ['acme.com'], attributeMapping: { department: 'department', title: 'title' }, }); // Give the IdP administrator: sso.entityId (audience) and sso.acsUrl (assertion consumer service, HTTP-POST). ``` `createConnection` reads the IdP's sign-on URL, entity ID, and signing certificates from the metadata file. It returns the two values the IdP needs in return: your entity ID (the audience its assertions must name) and your assertion consumer service (ACS) URL, where the browser posts the IdP's answer. Sign-in starts at `sso.loginUrl`. Enable `allowIdpInitiated` only if people launch the app from their IdP portal. When the IdP's signing certificate is about to expire, list the old and new certificates together with `updateConnection` (certificate rollover). Connection summaries show each certificate's expiry so you can warn before it lapses. Details are in [Tenant-managed connections](/docs/federation/saml#tenant-managed-connections). **OpenID Connect:** Use `createOAuthLogin` with `kind: 'microsoft'` for Entra ID over OpenID Connect. Pin `allowedMicrosoftTenants` to the customer's directory, or set `microsoftTenant` to their tenant ID, so no other Microsoft directory can sign in to Acme. For any other OIDC provider use `kind: 'oidc'`. Forward the discovered email as a hint, so the provider's page opens with the right account selected: ```ts const { url } = await login.begin(connectionId, undefined, { loginHint: email, domainHint: 'acme.com', }); ``` Details are in [OAuth and OIDC sign-in](/docs/federation/oauth-sign-in#microsoft-entra-id). `trustedEmailDomains` (SAML) or a verified-email claim (OIDC) lets the first sign-in create the account. Without them, people link the provider to an existing account explicitly. Federation never merges accounts on a matching email alone. ### Require it [#require-it] **Why:** as long as people can still sign in with a password, SSO is optional, and someone the company removed from its IdP could keep using a password they set earlier. Requiring federated sign-in closes that gap. **Who:** Acme's administrator, in your organization settings screen, once SSO works. Test a federated sign-in first: after this step, password sign-in is refused for everyone in the organization. ```ts await iam.api.tenants.setAuthPolicy(credential, { tenantId, authPolicy: { allowedMethods: ['federated'], requireMfa: true, allowedIpRanges: ['203.0.113.0/24'], }, }); ``` `tenants.setAuthPolicy` sets the organization's sign-in rules. Password and email-link sign-in are then refused for the organization before any credential is checked. `requireMfa` adds the product's own second factor on top of the IdP's. `allowedIpRanges` limits where sessions may be used from, for example the company's office or VPN ranges. See [Tenant sign-in policy](/docs/guides/authentication/tenant-policy) for every policy field and [`tenants.setAuthPolicy`](/docs/reference/api/tenants#setauthpolicy) for the signature. ### Let their directory provision people (SCIM in) [#let-their-directory-provision-people-scim-in] **Why:** SSO alone creates an account only when someone first signs in, and never removes it. With SCIM, the company's directory creates accounts before day one, updates them when people change teams, and deactivates them the moment someone leaves, even if they never sign in again. **Who:** Acme's administrator creates the connection in your admin UI and pastes its URL and token into the provisioning settings of their IdP. Once the IdP has pushed its groups, they map groups to roles. ```ts const connection = await scim.createConnection(credential, { tenantId, name: 'Okta provisioning' }); // Give the IdP: the connection base path (connection.path) and connection.token (shown once). await scim.setRoleMappings(credential, { tenantId, connectionId: connection.id, groupId, // the SCIM group ID, once the IdP has pushed the group roleIds: [viewerRoleId], }); ``` `createConnection` issues the bearer token the IdP uses to call your SCIM endpoints. `setRoleMappings` lets an administrator decide which roles a directory group grants. The directory then creates, updates, deactivates, and deletes accounts and groups. Deactivation revokes sessions immediately. Mapped groups carry their roles, so joining "Engineering" in Okta grants the engineering role here. `scim.rotateToken` replaces the token without losing provisioned state. See [SCIM inbound](/docs/federation/scim). ### Provision their applications (SCIM out) [#provision-their-applications-scim-out] **Why:** customers often want the people you manage to appear in their other SaaS tools, or your platform launches downstream services per organization. Provisioning them automatically also removes them automatically. **Who:** Acme's administrator adds each application as a target, with the SCIM token that application's admin console issued. Your team schedules the syncs once for the whole deployment. ```ts await provisioner.createTarget(credential, { tenantId, name: 'Slack', baseUrl: 'https://api.slack.com/scim/v2', token: slackScimToken, groupIds: [engineeringGroupId], pushGroups: true, }); ``` `createTarget` registers the application's SCIM URL and the token it issued. `previewTarget` shows what a sync would change before anything is written. `subscribe(iam.events)` and a periodic `syncAll()` keep the application current. The console's App provisioning page does all of this without code. See [SCIM outbound](/docs/federation/scim-outbound). ### Offboarding end to end [#offboarding-end-to-end] **Why:** this is the payoff of the previous steps. One change in the company's directory ends access in your product, in the connected applications, and in the systems that trust your tokens. **Who:** nobody, per leaver. Your team schedules the background jobs once: `provisioner.syncAll()`, `issuer.logoutEndedSessions()`, and `signals.dispatch()` (see [Scheduled jobs](/docs/operations/jobs#protocol-jobs)). When the company removes someone in its directory: 1. SCIM deactivates the identity here, and its sessions and API keys stop working. 2. The provisioner deactivates or deletes the person in every connected application at its next run, which its event subscription starts as soon as the audit event is dispatched. 3. OAuth grants bound to the ended sessions are revoked by `logoutEndedSessions()`, and clients registered with a `backchannelLogoutUri` receive an OpenID back-channel logout ([Back-channel logout](/docs/federation/oauth-provider#back-channel-logout)). Even before `logoutEndedSessions()` runs, the grant fails at its next use, because every token use rechecks the session. 4. Receivers registered as Shared Signals streams (the customer's security monitoring system, or applications that keep their own sessions) get a signed Security Event Token (SET) as soon as the event is dispatched: `session-revoked` when a session is revoked, `account-disabled` when the person is offboarded or reaches their scheduled expiry. A SCIM deactivation on its own is recorded as `iam:scim:UpdateUser`, which is not one of the [mapped events](/docs/federation/shared-signals#event-mapping). When receivers must hear about a leaver, also run the offboarding in the next item. 5. An administrator can also call [`identities.offboard`](/docs/reference/api/identities#offboard) to remove role bindings and group memberships and to hand owned resources to a successor in one audited step. ### Watch it [#watch-it] **Why:** enterprise customers and their auditors will ask who changed what, and you want to notice a broken integration before a leaver keeps access. **Who:** your team monitors the integrations across all customers; Acme's administrator can review their own organization's audit log and connection status. Every step above is audited: `iam:saml:*Connection`, `iam:scim:*` (inbound users and groups, outbound targets and syncs), `iam:oauth:*`, `tenant:auth-policy`, and the domain operations. Three tools help you watch them: * [webhooks](/docs/guides/events/webhooks) route the events to a security monitoring system (SIEM); * [`audit.verify`](/docs/reference/api/audit#verify) checks that the tamper-evident audit chain is intact; * [`analysis.findings`](/docs/reference/api/analysis#findings) reports risky configuration such as administrators without MFA or unused API keys. SCIM connection summaries (`lastUsedAt`) and provisioning run reports (`lastRun`) show whether each integration is still running. ## Next steps [#next-steps] - [SAML](/docs/federation/saml): Every connection option, IdP setup notes, and response validation rules. - [SCIM inbound](/docs/federation/scim): Filters, PATCH, Bulk, manager mapping, and connection administration. - [Tenant sign-in policy](/docs/guides/authentication/tenant-policy): Allowed methods, MFA, IP ranges, and session limits per organization. - [Webhooks](/docs/guides/events/webhooks): Deliver audit events to a SIEM or your own services. - [Protocol jobs](/docs/operations/jobs#protocol-jobs): The background work that makes offboarding reach every connected system. # Federation (/docs/federation) > Connect enterprise identity providers, act as an identity provider for your own apps, and provision people in and out with standard protocols. Federation means trusting another system to tell you who someone is, or telling another system yourself. It is how Better IAM fits into the identity systems around it. Your customers already have identity systems. A company that uses Okta or Microsoft Entra ID wants its people to sign in with those accounts, wants its directory to create and remove accounts in your product, and wants former employees to lose access everywhere the day they leave. Your own applications and APIs, in turn, want to rely on Better IAM for sign-in instead of handling passwords themselves. Standard protocols make each of these connections work without custom integration code on either side. ## The protocols in plain words [#the-protocols-in-plain-words] An **identity provider (IdP)** is the system that knows who a person is and signs them in: Okta, Microsoft Entra ID, Google Workspace, ADFS, or Better IAM itself. The application that trusts the IdP's answer is called the **service provider** in SAML and the **relying party** in OpenID Connect. * **OAuth 2.0** lets one application get a limited, revocable token for an account at another service, without ever seeing that account's password. OpenID Connect (OIDC) is a layer on top of OAuth that adds a signed **ID token** saying who signed in. Together they are the modern standard for "sign in with another account" and for API tokens. Google, GitHub, Microsoft, and every enterprise IdP speak them. * SAML 2.0 (Security Assertion Markup Language) is the older, XML-based single sign-on (SSO) standard. Many enterprise IdPs and IT teams still prefer it. * SCIM 2.0 (System for Cross-domain Identity Management) is a REST API for creating, updating, and removing user accounts and groups in another system. It automates the "joiner, mover, leaver" lifecycle. * The Shared Signals Framework (SSF) lets one system push security events to others as they happen. It has two event profiles: **CAEP** (Continuous Access Evaluation Profile) for session events such as "this session was revoked", and **RISC** (Risk Incident Sharing and Coordination) for account events such as "this account was disabled". The OAuth pages also use a few extensions. Each one is a published internet standard, identified by its RFC (Request for Comments) number: | Term | What it does | | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | PAR: pushed authorization requests (RFC 9126) | The app sends its sign-in request to the server directly first, so the request's details never travel through the browser's address bar. | | DPoP: demonstrating proof of possession (RFC 9449) | Binds an access token to a private key the app holds, so a stolen token is useless on its own. | | Token exchange (RFC 8693) | Lets one API trade a user's token for a new, narrower token to call another API on the same user's behalf. | | Dynamic client registration (RFC 7591) | Lets an app register itself with the authorization server over HTTP, instead of an administrator registering it by hand. | | Protected resource metadata (RFC 9728) | Lets an API publish which authorization server issues its tokens, so a client can find it without configuration. | ## Who configures what [#who-configures-what] Your team chooses which protocols to enable and mounts each one once, in code. After that, most connections are created at runtime, per organization, by that organization's administrator (usually the customer's IT team) through your admin UI, the console, or a script. No deployment is needed when a new customer connects. | Protocol | Your team, once per deployment | The organization's administrator, per customer | | ---------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | OIDC and OAuth sign-in | Mounts `createOAuthLogin` and adds each connection (client ID and secret) to the deployment configuration. | For enterprise SSO, registers your app in their IdP (for example an Entra ID app registration) and sends you the client ID, the secret, and their directory ID. | | SAML | Creates the service-provider key pair, mounts `createSamlService`, and builds an SSO settings screen. | Registers your app in their IdP and uploads the IdP's metadata file in your admin UI. | | SCIM inbound | Mounts `createScimService` and builds a provisioning screen. | Creates a connection, pastes its URL and token into the IdP, and maps directory groups to roles. | | SCIM outbound | Mounts `createScimProvisioner` and schedules its syncs. | Adds a target for each SaaS app, with the provisioning token that app issued. | | OAuth/OIDC provider | Runs `createOAuthProvider`, builds the sign-in and consent pages, and declares your APIs. | Registers the client applications that may use it. | | Shared Signals | Runs `createSharedSignalsTransmitter` and schedules its delivery job. | Adds a stream for each receiver (their security monitoring system or an app), with the endpoint and token the receiver provides. | ## Which protocol for which job [#which-protocol-for-which-job] Start from the job you need done. Each row names the protocol, the factory function that creates its service, and the page that covers it. | You want to | Protocol | Factory | Page | | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------- | | Let an organization sign in with its IdP (Okta, Entra ID, Google Workspace, ADFS) | OpenID Connect or SAML 2.0 | `createOAuthLogin`, `createSamlService` | [OAuth and OIDC sign-in](/docs/federation/oauth-sign-in), [SAML](/docs/federation/saml) | | Offer "Sign in with Google", GitHub, or Microsoft | OAuth 2.0 / OIDC | `createOAuthLogin` | [OAuth and OIDC sign-in](/docs/federation/oauth-sign-in) | | Be the identity provider for your own apps, CLIs, devices, and service accounts | OAuth 2.0 / OIDC authorization server | `createOAuthProvider` | [OAuth/OIDC provider](/docs/federation/oauth-provider) | | Protect your APIs with tokens meant only for them | Resource indicators, JSON Web Token (JWT) access tokens, DPoP, token exchange | `createAccessTokenVerifier` | [Resource servers and tokens](/docs/federation/oauth-resource-servers) | | Let AI assistants that speak the Model Context Protocol (MCP hosts) and other clients register themselves and call your APIs | Dynamic client registration, protected resource metadata | `registration`, `createResourceGuard` | [Dynamic registration and MCP](/docs/federation/mcp-authorization) | | Let a customer's directory push users and groups to you | SCIM 2.0 (inbound) | `createScimService` | [SCIM inbound](/docs/federation/scim) | | Push your members to SaaS apps such as Slack or GitHub | SCIM 2.0 (outbound) | `createScimProvisioner` | [SCIM outbound](/docs/federation/scim-outbound) | | Tell security monitoring systems (SIEMs) and apps about revoked sessions and disabled accounts | Shared Signals (CAEP, RISC) | `createSharedSignalsTransmitter` | [Shared Signals](/docs/federation/shared-signals) | ## How the data flows [#how-the-data-flows] Better IAM sits between your customers' identity systems and the applications that rely on you. Information comes in on the left and goes out on the right: Inbound protocols (OIDC and SAML sign-in, SCIM in) change identities, groups, and sessions. Outbound protocols (the OAuth provider, SCIM out, Shared Signals) read that state and keep the systems downstream of you consistent with it. An offboarding in the customer's directory therefore reaches every connected application: see [Offboarding end to end](/docs/federation/enterprise-onboarding#offboarding-end-to-end). ## Mount a protocol [#mount-a-protocol] Each protocol is a separate package that uses the same store, verified credential resolution, and authorization as the rest of Better IAM. Importing the umbrella's main entrypoint does not activate any of them: you configure and mount each protocol you need, so nothing is exposed that you did not ask for. ```ts title="iam.ts" import { createScimService } from 'better-iam/scim'; // Every factory takes the host callbacks plus its own options (see each protocol's page). const scim = createScimService({ ...iam.protocolHost }); iam.useProtocol(scim); ``` * `iam.protocolHost` is the bundle of trusted callbacks a protocol needs from your instance: the store, credential authentication, authorization checks, session validation, identity-attribute validation, and two callbacks that write to the directory. `completeAuthentication` turns a verified external identity into a session, and `syncRoleMappings` turns a directory group into role bindings. Spread it into every factory. * `iam.useProtocol(service)` mounts a protocol service under its base path, so the IAM handlers route its requests to it. * `iam.handler(request)` is the Fetch-compatible handler (for Next.js, Hono, SvelteKit, and similar). It dispatches every mounted protocol. * `iam.nodeHandler(req, res)` is the Node `http` handler. It does the same, and it can also serve protocols that need Node's own request and response objects. The OAuth authorization server is one of them, so it needs a Node HTTP server rather than a Fetch-only runtime. When a protocol is mounted through IAM, a successful federated sign-in sets the standard IAM session cookie, so the rest of your application sees an ordinary signed-in user. > **Standalone use.** You can also use the protocol packages without the IAM handler. Then you apply the returned session to your application's response yourself, and you must implement every host callback as a trusted server function. Never expose those callbacks as HTTP endpoints. For mount paths behind frameworks and proxies, see [Protocol mounts](/docs/operations/deployment/protocol-mounts). ## Shared guarantees [#shared-guarantees] Federation accepts input from systems you do not control, so every protocol package follows the same safety rules: * **Explicit linking.** External identities are keyed by tenant, provider, issuer, and subject. Federation never merges accounts because an email address matches, because whoever controls an email setting at another provider would otherwise control the account. * **HTTPS everywhere.** Configured endpoints, callbacks, receivers, and downstream services must use HTTPS. `allowInsecureLocalhost: true` permits plain HTTP to loopback addresses for local development only. * **Secrets at rest.** Client secrets, protocol artifacts, receiver headers, and downstream tokens are encrypted. Tokens Better IAM issues are stored as hashes and shown once. * **Audited.** Connection changes, provisioning, consents, token exchanges, and syncs append events to the tamper-evident [audit chain](/docs/guides/events/audit-chain). ## Packages [#packages] Install them individually, or use the `better-iam/oauth`, `better-iam/saml`, and `better-iam/scim` subpaths of the umbrella package. All factories are server-only. ## Pages in this section [#pages-in-this-section] - [Enterprise onboarding](/docs/federation/enterprise-onboarding): Take one customer from "we use Okta" to fully managed access, step by step. - [OAuth and OIDC sign-in](/docs/federation/oauth-sign-in): Google, GitHub, Microsoft Entra ID, any OIDC provider, and plain OAuth2. - [SAML](/docs/federation/saml): Tenant-managed SAML connections with metadata import and IdP-initiated sign-in. - [OAuth/OIDC provider](/docs/federation/oauth-provider): An authorization server for your own apps: clients, consent, grants, and logout. - [Resource servers and tokens](/docs/federation/oauth-resource-servers): JWT access tokens for your APIs, offline verification, DPoP, and token exchange. - [Dynamic registration and MCP](/docs/federation/mcp-authorization): Self-registering clients and protected resource metadata for MCP servers and hosts. - [Shared Signals](/docs/federation/shared-signals): Signed CAEP and RISC events pushed to SIEMs and applications. - [SCIM inbound](/docs/federation/scim): Connection-scoped SCIM 2.0 Users, Groups, filters, PATCH, and Bulk. - [SCIM outbound](/docs/federation/scim-outbound): Keep downstream SaaS directories in step with your members. ## Protocol references [#protocol-references] The protocol engines Better IAM builds on, and the SCIM specification, for when you need the underlying details: * [oidc-provider documentation](https://github.com/panva/node-oidc-provider/blob/main/docs/README.md), which powers the authorization server * [oauth4webapi](https://github.com/panva/oauth4webapi), which powers OAuth and OIDC sign-in * [Node-SAML](https://github.com/node-saml/node-saml), which powers SAML validation * [SCIM protocol, RFC 7644](https://www.rfc-editor.org/rfc/rfc7644.html) # Dynamic registration and MCP (/docs/federation/mcp-authorization) > Let MCP hosts and other self-configuring clients discover your authorization server, register with RFC 7591, and call your protected APIs. The **Model Context Protocol (MCP)** lets AI assistants and IDEs (the **MCP host**) call tools that your product exposes through an **MCP server**. When that server holds customer data, the assistant has to act as a particular person, with that person's permission, just like any other app. MCP uses OAuth for this. **The problem it solves.** Normally an administrator registers every OAuth client by hand and copies its client ID into the app. That cannot work for MCP: thousands of people run their own copy of a desktop assistant, and each copy connects to servers it has never seen. Two standards let such a client configure itself: * **Protected resource metadata** (RFC 9728): the MCP server publishes a small JSON document that says which authorization server issues its tokens. A client that gets a 401 reads it and knows where to go. * **Dynamic client registration** (RFC 7591): the client registers itself with the authorization server over HTTP and receives a client ID, within limits you set. After that the client runs the normal authorization code flow, the person signs in to Better IAM and approves the assistant, and the assistant calls the MCP server with a token meant only for it. Better IAM supplies both halves: * On the [OAuth/OIDC provider](/docs/federation/oauth-provider), `registration` turns on dynamic registration, limited to one tenant and to the scopes and APIs you allow. * In front of the MCP server (your API), `createResourceGuard` serves the protected resource metadata, verifies access tokens, and answers failures with the challenge MCP hosts expect. The same pieces work for any self-configuring OAuth client, not only MCP. **Who configures it.** Your team decides the registration policy in code and puts the guard in front of the MCP server. Tenant administrators can hand out registration tokens when they want tighter control than open registration, and they manage the registered clients like any other. The people using an assistant only sign in and approve it. ## How an MCP host connects [#how-an-mcp-host-connects] The host starts with nothing but the MCP server's URL. Each step below discovers the next piece, so no one has to configure the host by hand: The consent screen shows the registered `client_name`. Consenting binds the client to the person's IAM session, so signing out or deactivation revokes it like any other consent. ## Turn on dynamic registration [#turn-on-dynamic-registration] Declare the MCP server as a [resource server](/docs/federation/oauth-resource-servers) and configure `registration`: ```ts title="oauth.ts" const issuer = createOAuthProvider({ ...options, resourceServers: { 'https://mcp.example.com/mcp': { scopes: ['mcp:tools'] } }, registration: { // Optional: accept registrations without a token (what most MCP hosts do), for the tenant the host serves. anonymous: ({ headers }) => tenantForHost(headers.host) ? { tenantId: tenantForHost(headers.host)!, scopes: ['openid', 'offline_access', 'mcp:tools'], resources: ['https://mcp.example.com/mcp'], maxClients: 500, } : undefined, }, }); ``` The discovered `registration_endpoint` (`{issuer}/reg`) accepts registrations in two ways. Choose by how much you trust the clients: **Anonymous:** **Open registration, for public MCP servers.** Most MCP hosts register without any token. The `anonymous` hook decides what such a request gets: it receives the request's `headers` and `ip` and returns the policy (above: the tenant that owns the host name the request came to), or `undefined` to decline. Anonymous registrations are capped per tenant by `maxClients` (default 100), so an open endpoint cannot be used to create unlimited clients. **Registration token:** **Invited registration, for controlled rollouts.** A tenant administrator creates an initial access token (RFC 7591 section 3) and gives it to one partner, one team, or one installation. Only requests carrying it can register: ```ts const { token } = await issuer.createRegistrationToken(credential, { tenantId, name: 'Claude Desktop', scopes: ['openid', 'offline_access', 'mcp:tools'], resources: ['https://mcp.example.com/mcp'], maxClients: 1, expiresIn: 86400, }); ``` The client sends it as `Authorization: Bearer `. Each registration uses one of the token's `maxClients`. A registration request looks like this: ```http POST /oidc/reg Content-Type: application/json { "client_name": "Acme Assistant", "redirect_uris": ["http://127.0.0.1:33418/callback"], "grant_types": ["authorization_code", "refresh_token"], "token_endpoint_auth_method": "none", "scope": "openid offline_access mcp:tools" } ``` A missing, invalid, expired, or used-up registration token, a declined anonymous request, or a suspended tenant answers `401` with `error: invalid_token`. ### Registration policy [#registration-policy] The anonymous hook returns, and a registration token stores, the same limits: `createRegistrationToken` also takes `name` (required, to recognize the token later) and `expiresIn` in seconds (default 7 days, between one minute and one year). It requires `iam:oauth:clients:create`, returns the token once, and stores only its hash. ### What a registration may contain [#what-a-registration-may-contain] These limits keep a self-registered client from being more powerful than a hand-registered one: * The tenant comes from the token or the hook. A `tenant_id` in the request is ignored. * Only the `authorization_code` and `refresh_token` grants are allowed, always with PKCE. A self-registered client always acts for a person who consented. * Clients are public (`token_endpoint_auth_method: "none"`) unless the token or policy sets `allowConfidential`, which permits a client secret (`client_secret_basic` or `client_secret_post`). * Redirect URIs must use HTTPS, loopback HTTP on any port (how desktop apps receive the code, per the native-apps standard RFC 8252), or a reverse-domain custom scheme for native apps (`com.example.app:/callback`). * Nothing the provider would have to fetch can be registered: `jwks_uri`, `sector_identifier_uri`, `backchannel_logout_uri`, `initiate_login_uri`, request URIs, or custom lifetimes. Inline `jwks` is refused too. A stranger cannot make your server call URLs of their choosing. * A client that omits `scope` gets the allowance. One that asks for more is refused. It may request tokens only for the allowance's `resources`. ### Manage registered clients and tokens [#manage-registered-clients-and-tokens] New clients are audited as `iam:oauth:RegisterClient`, appear in `listClients` with `registeredVia` (the token ID or `anonymous`), and are managed like any other client. | Method | Permission | What it does | | ------------------------------------------------------------ | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `listRegistrationTokens(credential, { tenantId })` | `iam:oauth:clients:read` | Lists a tenant's tokens (name, limits, `used`, expiry, `revoked`) so administrators see what is outstanding. Never returns the token itself. | | `revokeRegistrationToken(credential, { tenantId, tokenId })` | `iam:oauth:clients:delete` | Stops a token from registering more clients, for example when a rollout ends. Clients it already registered stay. | | `updateClient`, `revokeClient` | `iam:oauth:clients:update`, `iam:oauth:clients:delete` | Change or remove a registered client, like any other. | Registration management (`registration_client_uri`, which would let a client edit itself) is not offered, so administrators change and revoke registered clients through `updateClient` and `revokeClient`. ## Protect the MCP server [#protect-the-mcp-server] `createResourceGuard` is everything the MCP server needs in front of its routes: it serves the protected resource metadata document, verifies access tokens, and answers failures in the form MCP hosts understand. ```ts title="mcp/server.ts" import { createResourceGuard } from 'better-iam/oauth'; const guard = createResourceGuard({ resource: 'https://mcp.example.com/mcp', authorizationServers: ['https://id.example.com/oidc'], scopes: ['mcp:tools'], requiredScopes: ['mcp:tools'], resourceName: 'Acme MCP', }); async function handle(request: Request): Promise { const { response, token } = await guard.check(request); // also serves /.well-known/oauth-protected-resource/mcp if (response) return response; return runMcp(request, token); // token.subject, token.tenantId, token.clientId, token.scopes } ``` `guard.check(request, { scopes })` returns either `{ response }`, which you send back as is, or `{ token }` for an authorized request. Pass `scopes` for routes that need more than `requiredScopes`. | Request | Answer | | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` the metadata URL | The metadata document, readable from any origin, cached for an hour. | | No `Authorization` header | `401` with `WWW-Authenticate: Bearer resource_metadata="…", scope="…"` and no error code, as the bearer-token standard (RFC 6750) requires. This is what starts discovery in the host. | | Invalid, expired, or wrong-audience token | `401 invalid_token`. | | Missing scopes | `403 insufficient_scope`, naming the scopes needed. | | Valid token | `{ token }` with the [verified token fields](/docs/federation/oauth-resource-servers#verify-tokens-in-your-api). | Bearer and DPoP-bound tokens are both accepted. The guard also exposes `metadataUrl` and its `verifier`. ### Lower-level pieces [#lower-level-pieces] If your framework already has its own auth middleware, use the parts: * `protectedResourceMetadata(options)` returns the RFC 9728 document as an object. * `protectedResourceMetadataUrl(resource)` returns where it lives: `/.well-known/oauth-protected-resource` inserted before the resource's path, for example `https://mcp.example.com/.well-known/oauth-protected-resource/mcp`. * `createProtectedResourceHandler(options)` serves the document (`GET`, `HEAD`, and CORS preflight) and returns `undefined` for every other request, so it can sit in front of your routing. * The verifier's `challenge(error, realm, { resourceMetadata, scopes })` builds a `WWW-Authenticate` value that points clients at the metadata. ## Refresh tokens for MCP clients [#refresh-tokens-for-mcp-clients] Refresh tokens follow OAuth 2.1 (the consolidated revision of OAuth 2.0 that MCP builds on) for these requests, so an assistant stays connected without asking again every 15 minutes. An authorization code without the `openid` scope yields a refresh token whenever the client is registered for the `refresh_token` grant, with no `offline_access` or `prompt=consent` needed. OpenID requests keep the OIDC rule: they need `offline_access`. Refresh tokens rotate on every use and end with the consenting session. ## Next steps [#next-steps] - [Resource servers and tokens](/docs/federation/oauth-resource-servers): Resource server options, the verifier, DPoP, and token exchange. - [OAuth/OIDC provider](/docs/federation/oauth-provider): Interaction pages, consent, and grant revocation. # OAuth/OIDC provider (/docs/federation/oauth-provider) > Run a tenant-aware OAuth 2.0 and OpenID Connect authorization server for your own apps, CLIs, devices, and service accounts. An **authorization server** is the service other applications send people to when they need them to sign in, and that hands those applications tokens afterwards. When you enable it, Better IAM becomes an identity provider like Google or Okta, but for your own product: "Sign in with Acme". **The problem it solves.** As a product grows, more software needs your users' identity: a second web app, a mobile app, a CLI, a partner integration, a TV app, a background job. Letting each of them collect passwords is insecure, and building a custom token scheme for each is slow. OAuth 2.0 and OpenID Connect are the standards all of these clients already speak. The person signs in once with Better IAM, approves the app (consent), and the app receives: * an **ID token** that says who signed in (OpenID Connect), * a short-lived **access token** to call your APIs, * and optionally a **refresh token** to get new access tokens without asking again. `createOAuthProvider` builds this server. Every client belongs to one tenant, every consent is bound to the IAM session that gave it, and ending that session ends the tokens. The protocol engine is [oidc-provider](https://github.com/panva/node-oidc-provider); Better IAM supplies the storage (encrypted, tenant-bound records in your database), the accounts, the consent rules, and client administration. **Who configures it.** Your team sets up the provider, declares your APIs, and builds its sign-in and consent pages once. Tenant administrators then register the applications (clients) that may use it, through an admin screen you build on the client methods below or a script, and people manage the apps they connected from a "Connected apps" page. ## What it supports [#what-it-supports] Each row is a standard OAuth or OpenID Connect feature. You do not have to use all of them: most products start with the authorization code flow and refresh tokens, and add the others when a client needs them. | Capability | What it is for | | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Discovery and JWKS | Clients read the server's endpoints and its JSON Web Key Set (JWKS, the public signing keys) from standard URLs, including `/.well-known/oauth-authorization-server{issuer path}`, instead of being configured by hand. | | Authorization code with PKCE | The standard browser sign-in flow. It is the only enabled response type, and PKCE (a per-request secret that makes a stolen code useless) is required for every client. | | Refresh tokens | Let an app stay signed in without asking again. They rotate on every use, and reusing an old one revokes the whole chain. | | Client credentials | Machine-to-machine access for a backend job, tied to an active service account. No person is involved. | | Device authorization | Sign-in for devices without a browser or keyboard (TVs, CLIs): the device shows a code, the person approves it on their phone or laptop. | | UserInfo | Returns the signed-in person's claims to a client holding an access token. | | Introspection and revocation | Let a client check whether a token is still valid, or cancel it. A client may only do this for its own tokens. | | RP-initiated logout | Lets an app (the relying party, RP) send the person to Better IAM to sign out, with your own logout page. | | Back-channel logout | Tells apps server-to-server that a person's session ended. See [Back-channel logout](#back-channel-logout). | | Pushed authorization requests (PAR, RFC 9126) | The client sends its sign-in request to the server first, so its parameters stay out of the browser URL. See [Pushed authorization requests](#pushed-authorization-requests). | | Resource indicators (RFC 8707) and JWT access tokens (RFC 9068) | Tokens meant for one API only, which that API verifies offline with the public keys. See [Resource servers and tokens](/docs/federation/oauth-resource-servers). | | DPoP (RFC 9449) | Binds a token to a key the client holds, so a stolen token is useless on its own. See [DPoP](/docs/federation/oauth-resource-servers#dpop). | | Token exchange (RFC 8693) | Lets one of your APIs call another for the same user with a new, narrower token. See [Token exchange](/docs/federation/oauth-resource-servers#token-exchange). | | Dynamic client registration (RFC 7591) | Lets clients such as MCP hosts register themselves over HTTP. Off unless `registration` is configured. See [Dynamic registration and MCP](/docs/federation/mcp-authorization). | The development interaction UI is disabled: login and consent pages belong to your application. ## Set up the provider [#set-up-the-provider] Create the provider with the host callbacks, persistent keys, and the URLs of your own sign-in pages, then mount it: ```ts title="oauth.ts" import { createOAuthProvider } from 'better-iam/oauth'; export const issuer = createOAuthProvider({ ...iam.protocolHost, issuer: 'https://identity.example/oidc', jwks: secrets.privateSigningJwks, cookieKeys: secrets.cookieSigningKeys, encryptionKey: secrets.base64Encoded32ByteEncryptionKey, trustedOrigins: ['https://identity.example'], scopes: ['documents:read'], interactionUrl: (uid) => `https://identity.example/interactions/${uid}`, renderDevicePage: ({ kind, form }) => renderDeviceScreen(kind, form), renderLogoutPage: ({ form }) => renderLogoutScreen(form), }); iam.useProtocol(issuer); ``` A **scope** is a named piece of access a client asks for, such as `email` or `documents:read`. Scope names are 1 to 128 letters, digits, and `: _ . -`. The provider always supports `openid`, `email`, `profile`, `offline_access` (ask for a refresh token), and `iam`, plus your `scopes` and every resource server's scopes. ### Keys [#keys] Supply persistent signing keys, cookie-signing keys, and a separate 32-byte encryption key. Keep the same keys across replicas and process restarts, and load them from your [secret store](/docs/operations/deployment/secrets). > **Never regenerate keys at startup.** Replacing the signing keys breaks verification of every JWT and ID token already issued, replacing the cookie keys ends pending interactions and provider sessions, and a new encryption key makes every stored protocol artifact unreadable. Signing-key rollover uses a JWKS containing the active private key and the still-valid verification keys. Changing the encryption key requires re-encrypting stored protocol artifacts; automatic key migration is not provided. ### Mount it [#mount-it] `iam.useProtocol(issuer)` registers the provider. `iam.nodeHandler(req, res)` then serves the complete authorization server: it passes every request under the issuer's path, and the metadata path `/.well-known/oauth-authorization-server{issuer path}`, to the provider on Node's request and response objects. You can also call `issuer.nodeHandler(req, res)` yourself. See [Protocol mounts](/docs/operations/deployment/protocol-mounts). The configured issuer stays fixed: metadata and generated endpoint URLs retain its mount path, so an issuer of `https://identity.example/oidc` serves its token endpoint at `https://identity.example/oidc/token` and its public keys at `https://identity.example/oidc/jwks`. ## Authorization code flow [#authorization-code-flow] This is what happens when a person clicks "Sign in with Acme" in one of your client apps: ## Build the interaction pages [#build-the-interaction-pages] An **interaction** is the part of the flow where a person has to do something: sign in, or approve a client. The provider hands it to your application, because the look of your sign-in and consent screens, and which sign-in methods they offer, are product decisions. * `issuer.interactionDetails(req, res)` tells your page what is being asked: which client, which tenant, which scopes. Call it on `GET` and render the screen. * `issuer.completeInteraction(req, res, { credential, consent })` records the person's answer and sends the browser back to the client. Call it on `POST`. ```ts title="interactions.ts" import type { IncomingMessage, ServerResponse } from 'node:http'; export async function interaction(req: IncomingMessage, res: ServerResponse) { const credential = { headers: toHeaders(req.headers) }; // the person's IAM session cookie if (req.method === 'GET') { const details = await issuer.interactionDetails(req, res); // details.client: name, logoUri, clientUri, policyUri, tosUri, firstParty // details.scopes and details.resources: what the client asks for res.setHeader('content-type', 'text/html; charset=utf-8'); res.end(renderConsentPage(details)); return; } const form = await readForm(req); await issuer.completeInteraction(req, res, { credential, consent: form.get('consent') === 'yes', }); } ``` If the person is not signed in yet, show your normal Better IAM sign-in (password, passkey, SSO, MFA) on the same page first; the consent step then uses the session it creates. `completeInteraction` enforces these rules: * The request is a `POST` whose `Origin` is in `trustedOrigins` (`CSRF` otherwise). * The credential must resolve to a user session in the client's tenant (`TENANT_MISMATCH` otherwise). Sign the person in to that tenant first. * Consent cannot be granted while an administrator [impersonates](/docs/guides/authentication/impersonation) the member (`IMPERSONATION_RESTRICTED`). * `consent: false` finishes the interaction with `access_denied`, which tells the client the person declined. > **Never trust identity from the form.** Pass the person's actual IAM credential (their session cookie or bearer token). Never pass account IDs or tenant IDs from a form as verified identity. `interactionDetails` returns: The forms passed to `renderDevicePage` and `renderLogoutPage` carry the provider's CSRF fields. Embed them in your page unchanged. The runnable example in `examples/shared` demonstrates the full browser flow. ### Branding and first-party apps [#branding-and-first-party-apps] Consent screens get the client's branding from `interactionDetails(...).client`: `name`, and the HTTPS `logoUri`, `clientUri`, `policyUri`, and `tosUri` set at registration. They also get `firstParty`, which marks the deployment's own applications. Asking people to "allow" your own mobile app to access their account is confusing, so your host may approve consent for first-party apps without asking by calling `completeInteraction` with `consent: true` once the person is signed in. The provider never skips the interaction by itself. ## Register clients [#register-clients] A **client** is an application allowed to use the provider. There are two kinds: * **Confidential clients** run on a server and can keep a secret: web apps with a backend, services. * **Public clients** run where a secret would leak: single-page apps, mobile and desktop apps, CLIs. They rely on PKCE instead of a secret. Clients are created with `issuer.registerClient(credential, input)`, which requires `iam:oauth:clients:create` on the `oauth-client` resource. A client's tenant and ID are immutable. **Web app:** ```ts const { clientSecret } = await issuer.registerClient(credential, { tenantId, clientId: 'reports-web', name: 'Reports', redirectUris: ['https://reports.example/callback'], postLogoutRedirectUris: ['https://reports.example/'], scopes: ['openid', 'email', 'profile', 'offline_access', 'documents:read'], logoUri: 'https://reports.example/logo.png', policyUri: 'https://reports.example/privacy', firstParty: true, }); // A confidential client receives its random secret once. Store it now. ``` **Browser or native app:** ```ts await issuer.registerClient(credential, { tenantId, clientId: 'reports-spa', name: 'Reports', public: true, redirectUris: ['https://reports.example/callback'], }); ``` Public clients use PKCE without a secret. They cannot use `client_credentials` or token exchange. Browser CORS access is limited to the origins of registered redirect URIs. **Service:** ```ts const { clientSecret } = await issuer.registerClient(credential, { tenantId, clientId: 'billing-sync', name: 'Billing sync', redirectUris: [], grantTypes: ['client_credentials'], serviceAccountId, scopes: ['documents:read'], }); ``` The client credentials grant is for software acting as itself, such as a nightly sync. The client must name an active service account in the same tenant, and its tokens carry that service account as `identity_id`, so your APIs know which non-human identity is calling. Deactivating the service account makes the client and its tokens unavailable. See [service accounts](/docs/reference/api/service-accounts). **Device:** ```ts await issuer.registerClient(credential, { tenantId, clientId: 'reports-tv', name: 'Reports for TV', public: true, redirectUris: [], grantTypes: ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'], }); ``` The device authorization grant is for devices where typing a password is impractical. The device asks the discovered device authorization endpoint for a code and shows it. The person opens the verification page on another device, where you render the provider's form with `renderDevicePage` (`kind: 'input'`, then `confirm` with the `userCode` and `clientName`, then `success`), and signs in and consents through your interaction page. Device codes expire after 10 minutes. `registerClient` returns `{ clientId, clientSecret, tenantId }`; `clientSecret` is only present for secret-based confidential clients. Registered redirect URIs are exact. Client secrets and protocol payloads are encrypted at rest, and token identifiers are hashed for storage. ### Manage clients [#manage-clients] After registration, these methods back an "Applications" page in your admin UI. Each checks the listed permission on the client: | Method | Permission | What it does and when to use it | | ----------------------------------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `listClients(credential, { tenantId, includeRevoked? })` | `iam:oauth:clients:read` per client | Lists the clients the caller may read, for an admin "Applications" page. Never returns a secret. | | `getClient(credential, { tenantId, clientId })` | `iam:oauth:clients:read` | Reads one client's settings. | | `updateClient(credential, { tenantId, clientId, ...settings })` | `iam:oauth:clients:update` | Changes settings, such as a new redirect URI, with the same validation as registration. | | `rotateClientSecret(credential, { tenantId, clientId, revokeTokens? })` | `iam:oauth:clients:update` | Issues a new secret once and disables the previous one immediately. Use it on a schedule, or at once when a secret leaked. | | `revokeClient(credential, { tenantId, clientId })` | `iam:oauth:clients:delete` | Retires an app: revokes the client and deletes every token, code, and grant issued to it. | `updateClient` changes the name, redirect URIs, grant types, scopes, resources, `requireDpop`, `requirePushedAuthorization`, lifetimes, branding, back-channel logout URI, and keys. Tenant, ID, client type, and service account stay immutable. Pass `null` to remove a branding URL or the back-channel logout URI, or to restore a default lifetime. > **Narrowing a client revokes its tokens.** Removing a grant type, scope, or resource, or turning on `requireDpop`, revokes every token, code, and consent issued to the client (the result says `tokensRevoked: true`), so no credential keeps authority the client no longer has. Pass `revokeTokens: true` to `rotateClientSecret` when the old secret leaked, so tokens obtained with it stop working too. Client summaries include `tokenEndpointAuthMethod`, `keyIds`, `jwksUri`, `registeredVia`, `secretRotatedAt`, and `firstParty`. Updates and rotations are audited as `iam:oauth:UpdateClient` and `iam:oauth:RotateClientSecret`. ### Key-based client authentication [#key-based-client-authentication] A shared secret has to be stored by both sides and can leak. With `private_key_jwt`, the client keeps a private key and proves itself by signing a short message instead, so the provider only ever holds its public key. Confidential clients default to `client_secret_basic`. Register with `tokenEndpointAuthMethod: 'client_secret_post'` to send the secret in the form body, or `'private_key_jwt'` with public keys in `jwks` or an HTTPS `jwksUri`: ```ts await issuer.registerClient(credential, { tenantId, clientId: 'ledger-api', name: 'Ledger', redirectUris: [], grantTypes: ['client_credentials'], serviceAccountId, tokenEndpointAuthMethod: 'private_key_jwt', jwks: { keys: [ledgerPublicJwk] }, }); ``` * A `private_key_jwt` client receives no secret. It gives either `jwks` (at most ten keys; private key members are refused) or `jwksUri`, not both. * It signs a short-lived assertion (`iss` and `sub` = client ID, `aud` = issuer, unique `jti`) for each token, introspection, or revocation request, and each assertion works once. * Rotate keys with `updateClient({ jwks })` or a new `jwksUri` without revoking tokens. ## Consent and connected apps [#consent-and-connected-apps] Each consent is a **grant**: the record that a person allowed a client certain scopes. Repeating consent in the same provider session extends the existing grant, so an account has one grant per client instead of a new one per sign-in. Grants expire 30 days after the last consent. Grants are what a "Connected apps" settings page shows, where people see which apps can access their account and disconnect the ones they no longer use: | Method | What it does | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `listGrants(credential, { tenantId, identityId?, clientId? })` | Lists live grants: client name, OIDC scopes and claims, resource scopes, creation, expiry. Revoked, expired, and session-orphaned grants are left out. | | `revokeGrant(credential, { tenantId, grantId })` | Disconnects one app, by the grant's opaque `id`. | | `revokeGrants(credential, { tenantId, identityId?, clientId? })` | Disconnects all of an account's apps, or one client's. Returns `{ revoked }`. | Revocation invalidates the grant's refresh tokens, access tokens, and codes at once and is audited as `iam:oauth:RevokeGrant`. People manage their own grants without extra permissions. Reading or revoking another account's grants, for example by support staff, requires `iam:oauth:grants:read` or `iam:oauth:grants:revoke` on `iam/{identityId}` in that account's tenant. ### Session binding [#session-binding] Every user grant is bound to the IAM session used to approve it. On every later token use the provider rechecks session expiry, logout, account deactivation, tenant suspension, the current MFA policy, and configured idle limits. This is why signing out of Better IAM, or being deactivated, also cuts off every app the person connected. `iam.protocolHost.validateSession` supplies the exact product policy; standalone integrations without that callback use a one-day idle maximum. Refresh-token reuse permanently revokes the grant family, including tokens issued by a racing request: a reused refresh token means someone copied it. Database transactions protect individual artifact operations and do not hold a writer lock while waiting for request bodies. ## Tokens and claims [#tokens-and-claims] A **claim** is one fact inside a token, such as the account's email. Tokens carry `tenant_id`; client-credentials tokens also carry `identity_id`, the service account. | Scope | Claims | | --------- | ----------------------------------- | | `openid` | `sub` (the account ID), `tenant_id` | | `email` | `email`, `email_verified` | | `profile` | `name` | | `iam` | `roles`, `groups`, `attributes` | The built-in `iam` scope adds `roles` and `groups` (the account's live role and group IDs when the claims are read, expired bindings excluded) and `attributes` (declared identity attributes) to the userinfo response. Per the standard rule, ID tokens issued alongside an access token carry only `openid` claims, so relying parties read these from userinfo. They can render navigation or map roles without a callback. The values are snapshots, and enforcement stays with Better IAM. > **Scopes are not permissions.** OAuth scopes are client-facing claims, not IAM permission grants. Resource servers should introspect or verify tokens, check their tenant and scope, and apply their product's authorization policy. OAuth tokens are not accepted as IAM administrative session credentials by default. ### Token lifetimes [#token-lifetimes] | Artifact | Lifetime | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | Access token | 15 minutes, or the target resource server's `accessTokenTtl`. A client's `accessTokenTtl` (60 to 86,400 seconds) can only shorten it. | | Refresh token | The client's `refreshTokenTtl` (5 minutes to 30 days, default 30 days). Restarts on every rotation, but never outlives the consent. | | Consent (grant) | 30 days from the last consent. | | Authorization code | 60 seconds. | | Pushed authorization request | 60 seconds. | | Device code, interaction | 10 minutes. | | ID token | 15 minutes. | Short access tokens limit the damage of a leaked token; refresh tokens keep the experience smooth. Pass `null` to `updateClient` to restore a default lifetime. ## Pushed authorization requests [#pushed-authorization-requests] Normally a client puts its whole authorization request (client ID, scopes, redirect URI) in the browser URL, where it can be read or tampered with and can grow too long. With **pushed authorization requests** (PAR, RFC 9126), the client first posts those parameters directly to the server at the discovered `pushed_authorization_request_endpoint`, gets back a short-lived `request_uri`, and sends only that through the browser. Require PAR for every client with `requirePushedAuthorizationRequests: true`, or per client with `requirePushedAuthorization`, for example for high-value integrations. Pushed requests expire after 60 seconds and cannot use unregistered redirect URIs. ## Back-channel logout [#back-channel-logout] When a person signs out of Better IAM, apps that created their own sessions from its tokens still consider them signed in. **Back-channel logout** (an OpenID standard) fixes that: the provider calls each app's server directly with a signed logout token, and the app ends its own session. Register an HTTPS `backchannelLogoutUri` on a client to receive them. `logoutEndedSessions({ identityId? })` does the work: * It finds consents whose IAM session expired, was revoked, or belongs to an account or tenant that is no longer active. * It posts a logout token signed with the provider keys (`sub` = account, `aud` = client, back-channel logout event) to each client once per account. * It revokes the grants and their tokens, and audits `iam:oauth:SessionLogout`. * It returns `{ sessions, grants, notified, failures }`. Deliveries time out after 2.5 seconds and are not retried. Sessions expire without an event, so call `logoutEndedSessions()` on an interval as well as after sign-out and deactivation events: ```ts title="jobs.ts" iam.events.subscribe(['auth:session:*', 'identity:*', 'tenant:*'], () => issuer.logoutEndedSessions(), ); setInterval(() => void issuer.logoutEndedSessions(), 60_000).unref(); ``` > **Outbound requests.** Outbound requests (client JWKS URIs and logout deliveries) refuse private and special-use addresses, so a client cannot point the provider at your internal network. `allowInsecureLocalhost` exempts loopback targets for local development only. See [Protocol jobs](/docs/operations/jobs#protocol-jobs) for running this job in production alongside the other scheduled work. ## Audit events [#audit-events] Every administrative change and every consent is recorded in the tenant's [audit chain](/docs/guides/events/audit-chain), so you can answer "who connected this app" or "who rotated this secret": | Action | When | | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `iam:oauth:RegisterClient` | A client was registered by an administrator or through dynamic registration. | | `iam:oauth:UpdateClient`, `iam:oauth:RotateClientSecret`, `iam:oauth:RevokeClient` | Client administration. | | `iam:oauth:Consent` | A person approved a client. | | `iam:oauth:RevokeGrant` | A grant was revoked. | | `iam:oauth:SessionLogout` | `logoutEndedSessions` ended a client's grants for an account. | | `iam:oauth:TokenExchange` | A token was exchanged (RFC 8693). | | `iam:oauth:CreateRegistrationToken`, `iam:oauth:RevokeRegistrationToken` | Registration token administration. | ## Next steps [#next-steps] - [Resource servers and tokens](/docs/federation/oauth-resource-servers): Audience-restricted JWT access tokens, offline verification, DPoP, and token exchange. - [Dynamic registration and MCP](/docs/federation/mcp-authorization): Let MCP hosts discover the server, register, and call your APIs. # Resource servers and tokens (/docs/federation/oauth-resource-servers) > Issue audience-restricted JWT access tokens for your APIs, verify them offline, bind them to keys with DPoP, and delegate calls with token exchange. In OAuth, a **resource server** is simply an API that accepts access tokens: your invoices API, your reports API, your MCP server. The [OAuth/OIDC provider](/docs/federation/oauth-provider) issues the tokens; the resource server checks them on every request. **The problem it solves.** A plain access token is valid wherever it is presented. If one API receives a token and that API is compromised or buggy, it could replay the token against every other API. And an API that has to ask the authorization server about every token adds a network call to every request. This page covers the four standards that fix those problems, in plain terms: * **Resource indicators** (RFC 8707): the client says which API it wants a token for, and gets a token that only that API accepts. * **JWT access tokens** (RFC 9068): the token is a JSON Web Token (JWT), a signed JSON document the API verifies locally with the provider's public key. No network call per request. * **DPoP** (RFC 9449, "Demonstrating Proof of Possession"): the token is bound to a key the client holds, so a stolen token is useless without that key. * **Token exchange** (RFC 8693): one API calls another on the user's behalf with a new, narrower token that records both who the user is and which API is acting. **Who configures it.** Your team declares the APIs in the provider configuration and adds token verification to each API. Tenant administrators decide which client applications may call which API when they register clients. ## Declare resource servers [#declare-resource-servers] Tell the provider which APIs exist, so it can issue tokens restricted to one of them. Add a `resourceServers` entry per API: ```ts title="oauth.ts" const issuer = createOAuthProvider({ ...options, resourceServers: { 'https://api.example': { scopes: ['invoices:read', 'invoices:write'], accessTokenTtl: 600 }, 'https://reports.example': { scopes: ['reports:read'] }, }, }); ``` Each key is a **resource indicator**: an absolute URI, without a query or fragment, that names the API. It usually is the API's base URL. ## Register clients for a resource [#register-clients-for-a-resource] Being declared is not enough: a client may only get tokens for the resources it was registered with. Anything else fails with `invalid_target`. This is how you decide, per application, which APIs it may call. ```ts await issuer.registerClient(credential, { tenantId, clientId: 'billing-sync', name: 'Billing sync', redirectUris: [], grantTypes: ['client_credentials'], serviceAccountId, scopes: ['invoices:read'], resources: ['https://api.example'], requireDpop: true, }); ``` ## Request a resource token [#request-a-resource-token] A client asks for an API's token by adding the `resource` parameter. It then gets an access token restricted to that audience, carrying only that API's scopes, in JWT format unless the server says `accessTokenFormat: 'opaque'`. Without `resource`, OpenID requests keep receiving UserInfo access tokens, which are only good for the provider's userinfo endpoint. **Service:** ```http POST /oidc/token Authorization: Basic Content-Type: application/x-www-form-urlencoded grant_type=client_credentials&resource=https://api.example&scope=invoices:read ``` **Browser app:** Browser clients add `resource` to the authorization request and to the code exchange: ```http GET /oidc/auth?client_id=reports-spa&response_type=code&scope=openid%20reports:read &resource=https://reports.example&code_challenge=...&code_challenge_method=S256 &redirect_uri=https://reports.example/callback&state=... ``` Consent grants each requested resource the requested scopes its resource server defines, and [`listGrants`](/docs/federation/oauth-provider#consent-and-connected-apps) shows them per resource. ## Verify tokens in your API [#verify-tokens-in-your-api] Every request to your API must prove it carries a valid token for this API with the right scopes. `createAccessTokenVerifier` checks JWT access tokens inside your API, without calling the provider. For client-credentials tokens it also reports the service account the client acts as: ```ts title="api/auth.ts" import { createAccessTokenVerifier } from 'better-iam/oauth'; const verifier = createAccessTokenVerifier({ issuer: 'https://identity.example/oidc', audience: 'https://api.example', }); const token = await verifier.verifyRequest( { authorization: req.headers.authorization, dpop: req.headers.dpop, method: req.method, url: fullUrl, }, { scopes: ['invoices:read'] }, ); // token.tenantId, token.clientId, token.identityId (service account), token.subject, token.scopes ``` By default the verifier fetches and caches `{issuer}/jwks`, refreshing it on an unknown `kid` (key ID), so signing key rollover needs no change in the API; pass `jwks` for pinned keys. It checks the signature, `typ: at+jwt`, issuer, audience, expiry, and scopes. A missing scope fails with `INSUFFICIENT_SCOPE` (403); everything else fails with `INVALID_TOKEN` (401). The verifier has three functions: * `verifyRequest(request, { scopes })` checks the `Authorization` header of a request, including the DPoP proof when the token is key-bound. Use it for every API request. * `verify(token, { scopes })` checks a bare token string. It refuses DPoP-bound tokens, because it has no proof to check, so use it only where tokens arrive outside HTTP headers. * `challenge(error, realm?, { resourceMetadata?, scopes? })` builds the `WWW-Authenticate` response header, which tells the client why it was refused. The optional arguments add a realm name, the [protected resource metadata](/docs/federation/mcp-authorization#protect-the-mcp-server) URL, and the scopes a `403` needs. A complete route handler: ```ts title="app/api/invoices/route.ts" import { IamError } from 'better-iam'; export async function GET(request: Request) { try { const token = await verifier.verifyRequest( { authorization: request.headers.get('authorization'), dpop: request.headers.get('dpop'), method: request.method, url: request.url, }, { scopes: ['invoices:read'] }, ); return Response.json(await listInvoices(token.tenantId!, token.subject)); } catch (error) { if (!(error instanceof IamError)) throw error; return new Response(null, { status: error.status, headers: { 'www-authenticate': verifier.challenge(error) }, }); } } ``` `createAccessTokenVerifier` takes these options: A verified token has these fields: Opaque tokens (`accessTokenFormat: 'opaque'`) cannot be verified offline and need the provider's introspection endpoint, which answers only for tokens issued to the calling client. Keep the default JWT format for APIs that other clients call. Either way, the token only says who is calling and with which scopes: check the tenant and apply your product's authorization policy. ## DPoP [#dpop] An ordinary ("bearer") token works for whoever holds it, like cash. **DPoP** makes it work only together with a private key the client generated and never shares. The client signs a small proof for every request (method, URL, time, and a hash of the token), and the API checks the proof against the key the token is bound to. Use it for clients whose tokens could leak through logs, proxies, or compromised devices. DPoP is on at the provider: a client that sends a `DPoP` proof receives a key-bound token (`token_type: DPoP`), and a client registered with `requireDpop` cannot get anything else. In the API, `verifyRequest` requires the `DPoP` authorization scheme and a proof for bound tokens. It checks the key thumbprint, method, URL (ignoring query and fragment), access-token hash, and age, and rejects replayed proofs. A plain bearer token sent with the `DPoP` scheme is refused too. > **Replay detection is per instance.** The verifier keeps replay state in memory per instance. A deployment with several API replicas should also enforce short proof lifetimes with `dpopMaxAge`. At the authorization server, `dpopNonceSecret` (32 bytes, base64, identical on every instance) enables server-provided nonces: the server hands out a fresh value that the next proof must include, which limits how long a pre-generated proof stays usable. ## Token exchange [#token-exchange] Sometimes an API needs to call another API for the same user. The invoices API, handling Ada's request, needs Ada's reports. Forwarding Ada's token does not work, because it is meant only for the invoices API. Using the invoices API's own service identity would lose track of who the request is for. **Token exchange** gives the invoices API a new token for the reports API that still names Ada as the subject and names the invoices API as the actor. Register the calling API as a confidential client with the `urn:ietf:params:oauth:grant-type:token-exchange` grant type, the downstream `resources`, and the `scopes` it may use there: ```ts await issuer.registerClient(credential, { tenantId, clientId: 'invoices-api', name: 'Invoices API', redirectUris: [], grantTypes: ['urn:ietf:params:oauth:grant-type:token-exchange'], scopes: ['reports:read'], resources: ['https://reports.example'], }); ``` Then exchange the caller's token: ```http POST /oidc/token Authorization: Basic Content-Type: application/x-www-form-urlencoded grant_type=urn:ietf:params:oauth:grant-type:token-exchange &subject_token= &subject_token_type=urn:ietf:params:oauth:token-type:access_token &resource=https://reports.example &scope=reports:read ``` The rules: * The subject token can be a JWT access token from this issuer or an opaque one it stores. It must belong to an active account in the exchanging client's tenant and must not be DPoP-bound. * Name the target with `resource` (or `audience`). It must be one of the client's registered resources. * The issued token keeps the account as `sub`, and names the exchanging client as `client_id` and as the actor in `act` (nested for chains). * It carries only the requested scopes allowed by both the client and the target resource server (all of them when `scope` is omitted), and expires no later than the subject token. * `actor_token` is refused, because the authenticated client is always the actor. Only access tokens can be requested. `authorizeTokenExchange(request)` adds your product policy after the built-in checks, for example which subject clients or scopes an API may delegate. Returning `false` answers `access_denied`. Without it, any client registered for the grant may exchange for its own resources and scopes. ```ts const issuer = createOAuthProvider({ ...options, authorizeTokenExchange: ({ clientId, subjectClientId, scopes }) => clientId === 'invoices-api' && subjectClientId === 'reports-web' && !scopes.includes('reports:admin'), }); ``` The request carries `tenantId`, `clientId` (the exchanging API), `identityId` (the user), `subjectClientId` (the app the user's token was issued to), `subjectScopes`, `resource`, and `scopes`. Each exchange is audited as `iam:oauth:TokenExchange`. `createAccessTokenVerifier` reports the chain as `actor`. ## Next steps [#next-steps] - [Dynamic registration and MCP](/docs/federation/mcp-authorization): Serve protected resource metadata in front of your API with createResourceGuard. - [OAuth/OIDC provider](/docs/federation/oauth-provider): Clients, consent, grants, and token lifetimes. # OAuth and OIDC sign-in (/docs/federation/oauth-sign-in) > Sign people in with Google, GitHub, Microsoft Entra ID, any OpenID Connect provider, or a plain OAuth2 provider, with PKCE and explicit account linking. OAuth 2.0 is the web standard for letting one application act with an account at another service, without ever seeing that account's password. OpenID Connect (OIDC) builds on it to answer "who is this person?": the provider returns a signed **ID token** that names the account. Together they power every "Sign in with Google" button and most enterprise single sign-on. **The problem it solves.** People do not want yet another password, and companies want their employees to sign in with the company account, under the company's password and MFA rules, so IT can remove access in one place. Federated sign-in gives both: the person proves who they are to Google, GitHub, Microsoft, or their company's identity provider (IdP), and your application trusts that answer. `createOAuthLogin` makes your application the **relying party** (the side that trusts the provider). You configure **connections**: one per provider and tenant, each with the client ID and secret you registered at that provider. Because every connection belongs to one tenant, each organization can have its own provider, client registration, and callback URL. **Who configures it.** Your team, in the deployment configuration: connections are fixed in code, not created at runtime. For a consumer button (Google, GitHub) you register one OAuth app at the provider yourself. For an enterprise customer, their IT administrator usually registers your app in their IdP (for example an Entra ID app registration) and gives you the client ID and secret, which you add as a connection. When customers should manage their own SSO without a deployment, use [tenant-managed SAML connections](/docs/federation/saml#tenant-managed-connections). The package handles the protocol and its security details. Better IAM decides who the person is: it maps the external identity to an account (an identity), enforces the tenant's MFA requirements, and issues the session. ## Set up a connection [#set-up-a-connection] Create the login service with the host callbacks from `iam.protocolHost` and one connection per provider and tenant, then mount it so the IAM handler serves its routes: ```ts title="iam.ts" import { createOAuthLogin } from 'better-iam/oauth'; const login = createOAuthLogin({ ...iam.protocolHost, trustedOrigins: ['https://product.example'], connections: [ { id: 'org-google', tenantId: organization.id, kind: 'google', clientId: secrets.googleId, clientSecret: secrets.googleSecret, redirectUri: 'https://product.example/oauth/google/callback', }, ], }); iam.useProtocol(login); ``` Register `redirectUri` as the callback URL at the provider: it is where the provider sends the browser back after sign-in. Then link to `/oauth/login/org-google` from your sign-in page. | Route | What it does | | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `GET /oauth/login/{connectionId}` | Starts sign-in: redirects (302) to the provider. Accepts `login_hint`, `domain_hint`, and `prompt` query parameters. | | `POST /oauth/login/{connectionId}` | Starts [linking](#link-an-existing-account) the provider to the signed-in account and answers `{ url }`. | | `GET` on the connection's `redirectUri` | The callback: finishes sign-in, clears the binding cookie, and (mounted through IAM) sets the session cookie. | `/oauth/login` is the default `basePath`. The callback path is whatever you registered as `redirectUri`, and each connection needs its own. ## How sign-in works [#how-sign-in-works] Sign-in uses the OAuth **authorization code flow**: the browser visits the provider, comes back with a short-lived code, and your server exchanges that code for tokens directly with the provider, so no token passes through the browser. Each protection in this flow stops a specific attack: * **State** is a random value that must come back unchanged. It is stored in the database, bound to the tenant and connection, expires after 10 minutes, and is deleted when the callback consumes it, so a callback cannot be forged or replayed (`OAUTH_STATE` otherwise). * **The binding cookie** ties the flow to the browser that started it. It is HTTP-only, `SameSite=Lax`, `__Host-` prefixed on HTTPS, and lives 10 minutes. An attacker cannot make your browser finish their sign-in. * **PKCE** (Proof Key for Code Exchange) sends a hash of a secret with the request and the secret itself with the code exchange, using `S256`, so an intercepted authorization code is useless on its own. * **Nonce** binds the ID token to this request. OIDC kinds (`oidc`, `google`, `microsoft`) send and check it and require an ID token, whose signature, issuer, and audience are validated too. * The callback URL must match the registered `redirectUri` exactly (origin and path). When the login service is mounted through IAM, a successful callback sets the standard IAM session cookie. Without the IAM handler, call the two steps yourself: `login.begin(connectionId)` returns the provider `url` and a `binding` value to keep in an HTTP-only cookie, and `login.callback(connectionId, callbackUrl, binding)` verifies the response and returns the session, which you apply to your response. ## Providers [#providers] Pick a preset with `kind`. Presets know the provider's endpoints and how it reports a verified email. **Google:** ```ts { id: 'org-google', tenantId, kind: 'google', clientId, clientSecret, redirectUri } ``` Google uses its fixed OIDC issuer, `https://accounts.google.com`, with discovery. Default scopes are `openid email profile`. The email counts as verified when the ID token says `email_verified: true`. A `domainHint` becomes Google's `hd` parameter, which limits the account picker to one Google Workspace domain. **GitHub:** ```ts { id: 'github', tenantId, kind: 'github', clientId, clientSecret, redirectUri } ``` GitHub is plain OAuth 2.0 without ID tokens, so Better IAM calls GitHub's authenticated profile API and uses the stable numeric account ID as the subject (issuer `https://github.com`), never the username, which people can change. The email is the account's primary, verified address from the emails API. Default scopes are `read:user user:email`, and a client secret is required. `loginHint` becomes GitHub's `login` parameter. **Microsoft:** ```ts { id: 'acme-entra', tenantId, kind: 'microsoft', clientId, clientSecret, redirectUri, microsoftTenant: 'organizations', allowedMicrosoftTenants: ['c5a7f7e2-1b4d-4c55-9a53-2f0f5d2b8e11'], // Acme's Entra tenant ID } ``` Signs in with Microsoft Entra ID (formerly Azure AD). See [Microsoft Entra ID](#microsoft-entra-id) for directory selection, the tenant allowlist, and email verification. **Any OIDC:** ```ts { id: 'acme-okta', tenantId, kind: 'oidc', issuer: 'https://acme.okta.com', clientId, clientSecret, redirectUri, } ``` `kind: 'oidc'` works with any OpenID Connect provider: Okta, Auth0, Keycloak, Ping, and others. Give its `issuer` and Better IAM reads the rest from the provider's discovery document. Every discovered endpoint must use HTTPS. The email counts as verified when the ID token says `email_verified: true`. **OAuth2:** ```ts { id: 'legacy-sso', tenantId, kind: 'oauth2', issuer: 'https://sso.legacy.example', authorizationEndpoint: 'https://sso.legacy.example/oauth/authorize', tokenEndpoint: 'https://sso.legacy.example/oauth/token', userInfoEndpoint: 'https://sso.legacy.example/api/me', clientId, clientSecret, redirectUri, scopes: ['profile'], mapProfile: (profile) => ({ subject: String(profile.id), email: typeof profile.email === 'string' ? profile.email : undefined, emailVerified: profile.email_verified === true, name: typeof profile.name === 'string' ? profile.name : undefined, }), } ``` Use `kind: 'oauth2'` for a provider that speaks OAuth 2.0 but not OIDC, so there is no standard ID token. Give `issuer`, `authorizationEndpoint`, `tokenEndpoint`, `userInfoEndpoint`, and `mapProfile(profile)`. The mapper runs only on a successful, authenticated profile response and must return a stable `subject`; it can also supply `email`, `emailVerified`, and `name`. These are server configuration fields, never request parameters. > **Choose a stable subject.** Never use an email address or a mutable username as the `subject` of an OAuth2 mapping. The subject is the key that ties the external account to the local one; if it can change hands, so can the account. ## Microsoft Entra ID [#microsoft-entra-id] `kind: 'microsoft'` signs in with Microsoft Entra ID. Entra has one sign-in endpoint for many directories (one per customer company), so the connection has to say which directories it accepts. * `microsoftTenant` selects the directory: `organizations` (the default, any work or school tenant), `common` (work, school, and personal accounts), `consumers` (personal accounts only), or one tenant ID or domain. * `issuer` may point at a sovereign-cloud authority instead of `https://login.microsoftonline.com`. Every discovered endpoint must live on that authority. * Multi-tenant settings (`organizations`, `common`, `consumers`) require `allowedMicrosoftTenants`: the Entra tenant IDs (`tid`) that may sign in. A directory you have not approved cannot create or reach accounts in this tenant; it fails with `OAUTH_TENANT`. * The ID token must come from the concrete issuer of its own `tid`, and the external identity is keyed by that issuer. * `domainHint` becomes Microsoft's `domain_hint`, which sends people straight to their company's sign-in page. > **Email verification in Entra ID.** Entra lets directory administrators set any email address, so the `email` claim counts as verified only when the token carries `xms_edov: true` (the email's domain is verified in that directory). Enable that optional claim in the app registration if first sign-in should enroll accounts by email. For a single customer, either pin `allowedMicrosoftTenants` to their directory or set `microsoftTenant` to their tenant ID. ## Options [#options] A connection tells Better IAM which provider to use, which tenant its sign-ins belong to, and which client registration to present. Each connection takes: The service itself (`createOAuthLogin`) takes the connections plus a few shared settings: Configured endpoints and registered callback URLs must use HTTPS. `allowInsecureLocalhost: true` enables HTTP only for loopback addresses. ## Sign-in hints [#sign-in-hints] Sign-in hints skip steps on the provider's page. Use them after [home-realm discovery](/docs/reference/api/domains#discover) has matched an email to a connection, so the person does not type their email twice or pick an account from a list: ```ts const { url } = await login.begin(connectionId, undefined, { loginHint: 'ada@acme.com', domainHint: 'acme.com', prompt: 'select_account', }); ``` Or on the `GET` start route: `/oauth/login/acme-entra?login_hint=ada%40acme.com&domain_hint=acme.com`. | Hint | Effect | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `loginHint` | Pre-fills the account, usually an email address. GitHub receives it as `login`. | | `domainHint` | Becomes Microsoft's `domain_hint` or Google's `hd`, skipping the account picker for that organization. | | `prompt` | `login` forces re-authentication, `select_account` shows the account picker, `consent` asks for consent again, and `none` fails instead of showing any page. Not sent to GitHub. | Hints are validated and forwarded only. They never influence which identity the callback accepts. ## First sign-in and account linking [#first-sign-in-and-account-linking] External identities are keyed by tenant, provider, issuer, and subject. What happens at the callback: | Situation | Result | | -------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | The external identity is already linked | The linked account signs in. | | Not linked, verified email, no account with that email in the tenant | A new account is enrolled with a verified email and linked. | | Not linked, email unverified or missing | Refused with `VERIFIED_EMAIL_REQUIRED`. | | Not linked, an account with that email already exists | Refused with `ACCOUNT_LINK_REQUIRED` (409): the person must link explicitly. | Same-email identities never merge automatically: otherwise anyone who could set that email address at some provider could take over the account. Federation invokes the tenant's local MFA requirements before it issues a session, so a tenant that requires MFA still asks for the product's second factor. See [error codes](/docs/reference/errors) for every code above. ### Link an existing account [#link-an-existing-account] Linking connects a provider to an account that already exists, for example when someone who signed up with a password wants to use Google from now on. The person signs in first, reauthenticates, and then `POST`s to the same start URL: ```ts title="Browser" const response = await fetch('/oauth/login/org-google', { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json', 'x-better-iam': '1' }, body: '{}', }); const { url } = await response.json(); location.assign(url); ``` The browser supplies `Origin`. Linking requires: * an `Origin` in `trustedOrigins`, the `X-Better-IAM: 1` header, and a JSON content type, so other sites cannot start it; * a current user session authenticated within the last five minutes (`RECENT_AUTH_REQUIRED` otherwise, see step-up authentication), so a borrowed, unlocked laptop is not enough; * the same target tenant as the connection; * an account that is not a root administrator. Root administrators cannot use the ordinary linking flow. Only session and identity IDs are saved with the browser-bound ceremony; raw IAM credentials are never stored. At the callback the host rechecks the original session and identity and rejects competing mappings: an external identity already linked to another account fails with `ACCOUNT_LINK_CONFLICT`. A new link is audited as `identity:link-provider`. The direct equivalent is `login.begin(connectionId, credential)` followed by `login.callback(connectionId, callbackUrl, binding)`. Protect the returned `binding` like the HTTP-only cookie the built-in handler uses. ## Map directory attributes [#map-directory-attributes] Your policies can use facts about people, such as their department. When the company's IdP already knows them, `mapAttributes(claims)` copies them in at every sign-in. OIDC, Google, and Microsoft pass the verified ID token claims; GitHub and OAuth2 pass the authenticated profile. ```ts { id: 'acme-okta', tenantId, kind: 'oidc', issuer: 'https://acme.okta.com', clientId, clientSecret, redirectUri, mapAttributes: (claims) => ({ department: typeof claims.department === 'string' ? claims.department : undefined, }), } ``` The mapped values are validated against `permissions.identityAttributes` inside the sign-in transaction and replace the identity's stored attributes on every sign-in, so directory data such as a department drives `principal.department` conditions in your [policies](/docs/guides/authorization/conditions). Return `undefined` to leave the stored attributes untouched. An invalid mapping fails the sign-in closed. ## Next steps [#next-steps] - [SAML](/docs/federation/saml): For identity providers that speak SAML 2.0, with tenant-managed connections. - [Tenant sign-in policy](/docs/guides/authentication/tenant-policy): Require federated sign-in and MFA for an organization. - [Enterprise onboarding](/docs/federation/enterprise-onboarding): Domains, SSO, SCIM, and offboarding for one customer, end to end. # SAML (/docs/federation/saml) > Accept SAML 2.0 single sign-on from Okta, Entra ID, ADFS, and Google Workspace, with tenant-managed connections, metadata import, and IdP-initiated login. SAML 2.0 (Security Assertion Markup Language) is the long-established standard for enterprise single sign-on (SSO): one company account that opens every work application. The company's **identity provider** (IdP: Okta, Microsoft Entra ID, ADFS, Google Workspace) signs an XML document, the **assertion**, that says who the person is. The browser carries it to your application, the **service provider** (SP), which checks the signature and signs the person in. **The problem it solves.** Many enterprise customers require SSO before they buy, and their IT teams often prefer SAML because every IdP supports it and their existing apps use it. With SSO, people use their company account under the company's password and MFA rules, and IT removes access in one place. (If the customer's IdP supports OpenID Connect, [OAuth and OIDC sign-in](/docs/federation/oauth-sign-in) works too.) `createSamlService` makes Better IAM a SAML service provider. Each connection has a fixed tenant, IdP issuer, SP audience, and callback URL. Connections come from two places, and both work side by side: * **Tenant-managed connections** live in the database. An organization's administrator creates one at runtime by uploading their IdP's metadata file, with no deployment change. This is what most products want. * **Configured connections** are fixed in your deployment configuration. **Who configures it.** Your team sets up the service-provider key pair once and builds an SSO settings screen that calls the connection methods below. The customer's IT administrator then does the rest: they register your app in their IdP and upload the IdP's metadata file on that screen. No deployment is needed per customer. A few SAML terms you will meet on this page: | Term | Meaning | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | Entity ID | A unique name for each side. Your SP entity ID is also the **audience** the assertion must be addressed to. | | ACS URL | The assertion consumer service: the URL on your side where the browser posts the IdP's response. | | Metadata | An XML file describing one side: its entity ID, URLs, and signing certificates. Exchanging metadata sets up the trust. | | `NameID` | The IdP's identifier for the person inside the assertion. | | `RelayState`, `InResponseTo` | Values that tie a response to the sign-in request your side started. | Validation is built on [Node-SAML](https://github.com/node-saml/node-saml). ## SP-initiated sign-in [#sp-initiated-sign-in] "SP-initiated" means the person starts at your application, which sends them to the IdP and waits for the answer: The request ID and `RelayState` are single use and expire after 10 minutes. When the service is mounted through IAM, a successful sign-in sets the standard IAM session cookie. ## Tenant-managed connections [#tenant-managed-connections] Tenant-managed connections let each organization connect its own IdP at runtime, which is how a multi-tenant product offers SSO to many customers. Give the service one service-provider identity (a key pair and certificate) shared by every managed connection, plus the host's authorization checks: ```ts title="iam.ts" import { createSamlService } from 'better-iam/saml'; export const saml = createSamlService({ ...iam.protocolHost, serviceProvider: { baseUrl: 'https://identity.example', privateKey: secrets.samlSpKey, publicCertificate: secrets.samlSpCertificate, }, }); iam.useProtocol(saml); ``` Then an organization administrator connects their IdP: ```ts const connection = await saml.createConnection(credential, { tenantId: organization.id, id: 'acme-okta', name: 'Acme Okta', metadataXml: uploadedIdpMetadata, trustedEmailDomains: ['acme.com'], attributeMapping: { department: 'department' }, }); // connection.entityId, connection.acsUrl → register them at the IdP ``` `createConnection` reads the IdP's sign-on URL, entity ID, and signing certificates from the metadata, stores the connection, and returns the values the IdP needs in return. Each managed connection gets fixed URLs under the base URL: | URL | Use | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | `{baseUrl}/saml/{id}/metadata` | Signed SP metadata, which some IdPs can import directly. Also the SP entity ID (the audience): `connection.entityId`. | | `{baseUrl}/saml/{id}/acs` | The HTTP-POST assertion consumer service: `connection.acsUrl`. | | `{baseUrl}/saml/{id}/login` | Where sign-in starts: `connection.loginUrl`. Link to it from your sign-in page. | `/saml` is the default `basePath`. Connection IDs are 2 to 63 lowercase letters, digits, and hyphens, because they appear in these URLs; without `id`, one like `saml-3f9a1c0b2d4e` is generated. IdP details come from `metadataXml` or from explicit `entryPoint`, `idpIssuer`, and `idpCertificates`. Explicit values override imported ones. ### Manage connections [#manage-connections] These methods back an SSO settings screen. Each one checks the caller's permission on the connection in its tenant, so only administrators who hold the `iam:saml:connections:*` permissions there can change it: | Method | Permission | What it does | | ---------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `createConnection(credential, input)` | `iam:saml:connections:create` on `saml/{id}` | Adds an IdP. Audited as `iam:saml:CreateConnection`. | | `listConnections(credential, { tenantId })` | `iam:saml:connections:read` on `saml/*` | Lists the tenant's connections, for an SSO settings page. | | `getConnection(credential, { tenantId, connectionId })` | `iam:saml:connections:read` | Reads one connection. | | `updateConnection(credential, { tenantId, connectionId, ...changes })` | `iam:saml:connections:update` | Changes settings, uploads refreshed metadata, or rolls certificates. Audited as `iam:saml:UpdateConnection`. | | `deleteConnection(credential, { tenantId, connectionId })` | `iam:saml:connections:delete` | Removes the connection and its pending sign-ins. Audited as `iam:saml:DeleteConnection`. | Permissions apply in the connection's tenant. Connection summaries include the values to enter at the IdP (`entityId`, `acsUrl`, `metadataUrl`, `loginUrl`) and, for each certificate, its SHA-256 fingerprint, subject, validity, and `expired`, so certificate rollover can be monitored. External identities linked through a deleted connection stay with their accounts. Configured `connections` keep working alongside managed ones, and their IDs are reserved. To offer the signed SP metadata XML as a download in your admin UI, call `getMetadata(id)`, which covers both kinds of connection; the synchronous `metadata(id)` covers configured ones only. ### Register the app at the IdP [#register-the-app-at-the-idp] The IdP administrator needs two values from the connection: the entity ID (audience) and the ACS URL. Better IAM requires both the SAML response and the assertion to be signed, and reads the email address from an attribute named `email` or `mail` (or the `urn:oid:0.9.2342.19200300.100.1.3` OID). **Okta:** 1. Create a SAML 2.0 app integration. 2. Set **Single sign-on URL** to `connection.acsUrl` and **Audience URI (SP Entity ID)** to `connection.entityId`. 3. Keep both the response and the assertion signed. 4. Add attribute statements, for example `email` from `user.email` and `department` from `user.department`. 5. Download the IdP metadata from the app's **Sign On** tab and pass it as `metadataXml`. **Entra ID:** 1. In **Enterprise applications**, create your own (non-gallery) application and choose SAML single sign-on. 2. Set **Identifier (Entity ID)** to `connection.entityId` and **Reply URL (Assertion Consumer Service URL)** to `connection.acsUrl`. 3. Under the SAML signing certificate, set the signing option to **Sign SAML response and assertion**. 4. In **Attributes & Claims**, add a claim named `email` (for example from `user.mail`) and any attributes you map. 5. Download the **Federation Metadata XML** and pass it as `metadataXml`. **Google Workspace:** 1. In the Admin console, add a custom SAML app under **Web and mobile apps**. 2. Download the IdP metadata and pass it as `metadataXml`. 3. Set **ACS URL** to `connection.acsUrl` and **Entity ID** to `connection.entityId`, and check **Signed response** so the whole response is signed. 4. Map **Primary email** to an attribute named `email`, plus any directory attributes you map. Then share `connection.loginUrl`, or route people to it from home-realm discovery (see [Enterprise onboarding](/docs/federation/enterprise-onboarding)). ### Import IdP metadata [#import-idp-metadata] `createConnection` and `updateConnection` parse `metadataXml` with `parseIdpMetadata`, which is also exported, for example to preview an upload before saving it: ```ts import { parseIdpMetadata } from 'better-iam/saml'; const idp = parseIdpMetadata(xml); // idp.entityId, idp.entryPoint (HTTP-Redirect sign-on URL), idp.certificates (PEM), idp.singleLogoutUrl? ``` It reads the entity ID, the HTTP-Redirect sign-on URL, and every signing certificate. It refuses DTDs, entities, documents over 512 KiB, and documents that do not describe exactly one IdP. > **Metadata signatures are not checked.** Import metadata only from administrators or from the IdP's own HTTPS URL. Whoever controls the metadata controls which certificates Better IAM trusts for the connection. ### Certificate rollover [#certificate-rollover] IdP signing certificates expire, typically every one to three years, and the IdP then switches to a new one. If Better IAM does not know the new certificate yet, sign-in breaks. To avoid that, list the old and new certificates together through `updateConnection` until the IdP switches, then remove the old one: ```ts await saml.updateConnection(credential, { tenantId, connectionId: 'acme-okta', idpCertificates: [currentCertificate, nextCertificate], }); ``` Refreshed metadata that already lists both certificates works the same way. Watch `certificates[].notAfter` and `expired` in connection summaries to warn administrators before a certificate lapses. ## Configured connections [#configured-connections] For connections fixed in your deployment, for example a single-tenant installation, pass `connections`: ```ts const saml = createSamlService({ ...iam.protocolHost, connections: [ { id: 'acme', tenantId: acme.id, entryPoint: 'https://acme.okta.com/app/product/exk1a2b3c4/sso/saml', idpIssuer: 'http://www.okta.com/exk1a2b3c4', idpCertificates: [secrets.acmeIdpCertificate], entityId: 'https://product.example/saml/acme/metadata', callbackUrl: 'https://product.example/saml/acme/callback', privateKey: secrets.samlSpKey, publicCertificate: secrets.samlSpCertificate, trustedEmailDomains: ['acme.com'], }, ], }); ``` The service itself (`createSamlService`) takes these options, shared by both kinds of connection: ## Routes [#routes] Once mounted with `iam.useProtocol`, the service answers these routes itself: | Route | What it does | | ------------------------------------ | ---------------------------------------------------------------------------------------------------------- | | `GET {basePath}/{id}/login` | Starts SP-initiated sign-in and redirects (302) to the IdP. | | `POST {basePath}/{id}/login` | Starts [account linking](#first-sign-in-and-account-linking) for the signed-in account; answers `{ url }`. | | `GET {basePath}/{id}/metadata` | Signed SP metadata (`application/samlmetadata+xml`), for IdPs that import it. | | `POST {basePath}/{id}/acs` | The assertion consumer service of a tenant-managed connection. | | `POST` on a configured `callbackUrl` | The assertion consumer service of a configured connection. | The assertion consumer service accepts only the SAML HTTP-POST binding. Every failure answers `401` with `{ "error": "SAML_INVALID" }` and no further detail, so an attacker learns nothing from probing it. Without the IAM handler, call the steps yourself: `begin(connectionId, credential?)` returns the IdP `url` plus `relayState` and a `binding` value to keep in a cookie, and `callback(connectionId, { samlResponse, relayState, binding })` validates the response and returns the session. ## Response validation [#response-validation] Every response goes through these checks, each of which blocks a known SAML attack: * Both the response and the assertion must be signed, so no part can be swapped. * The IdP issuer must match exactly, and so must the SP audience, the response `Destination`, and every subject `Recipient` (the ACS URL), so an assertion meant for another app is refused. * The assertion age is bounded (five minutes, with 30 seconds of clock skew), and the response must answer an outstanding `InResponseTo` request. * A persistent connection-scoped cache and a transaction around verification prevent replay across processes. * The `RelayState` and request ID are bound to a `Secure`, HTTP-only, `SameSite=None` cookie, so the response is only accepted in the browser that started the sign-in. * DTDs and entity declarations are rejected (they enable XML attacks), and so are oversized responses. * Better IAM signs its metadata and requests with SHA-256. ### Encrypted assertions [#encrypted-assertions] Assertions are signed but, by default, readable by anyone who sees them in the browser. Some organizations require them encrypted. Supply `decryptionPrivateKey` and `decryptionCertificate` (on a configured connection, or on `serviceProvider` for managed ones) to accept encrypted assertions. `requireEncryptedAssertions: true` rejects plaintext assertions. ## First sign-in and account linking [#first-sign-in-and-account-linking] The external identity is keyed by the connection, the IdP issuer, and the assertion's `NameID`. The name comes from `displayName`. * SAML email attributes are unverified by default, because an IdP administrator can type any address. For first-login enrollment, explicitly configure `trustedEmailDomains`, and only for domains whose account email attributes this IdP is authorized to verify. An email in a trusted domain counts as verified, so the first sign-in can create the account. * Otherwise the person signs in through an already linked external identity, or links an existing, authenticated local account. * Matching an existing email alone never links it. An unverified email fails with `VERIFIED_EMAIL_REQUIRED`, and a verified one that belongs to an existing account fails with `ACCOUNT_LINK_REQUIRED`. To link, `POST` to the login path with the same trusted-origin and recent-authentication requirements as [OAuth linking](/docs/federation/oauth-sign-in#link-an-existing-account): a trusted `Origin`, `X-Better-IAM: 1`, a JSON body, and a user session in the connection's tenant authenticated within five minutes. Root administrators cannot link this way. Federation invokes the tenant's local MFA requirements before issuing a session. ## Map attributes [#map-attributes] The IdP usually knows facts your policies can use, such as a person's department or cost center. `attributeMapping` on a managed connection copies them in, mapping identity attribute names to SAML attribute names and taking the first value of multi-valued attributes: ```ts attributeMapping: { department: 'department', title: 'title', costCenter: 'costCenter' } ``` Configured connections use `mapAttributes(profile)` with the validated assertion profile instead. Either way the values reach `completeAuthentication`, are validated against `permissions.identityAttributes` inside the sign-in transaction, and replace the identity's stored attributes on every sign-in, so directory data drives `principal.department`-style [policy conditions](/docs/guides/authorization/conditions). An invalid mapping fails the sign-in closed. ## IdP-initiated sign-in [#idp-initiated-sign-in] "IdP-initiated" means the person starts in their IdP's portal (app tiles in Okta or the Entra My Apps page) and clicks your app, so the IdP sends a response nobody asked for. It is off by default. Set `allowIdpInitiated: true` on a connection to accept such responses at its callback URL. * They must carry no `InResponseTo` and pass the same signature, issuer, audience, destination, recipient, and five-minute age checks. * Each assertion ID is accepted once per connection, recorded in `samlAssertions` for ten minutes. * The response signs the person in without account linking, and a `RelayState` is ignored. * Responses that do carry `InResponseTo` still need the browser-bound request. * `idpInitiated(connectionId, samlResponse)` is the direct call, for when you receive the POST yourself instead of through the built-in assertion consumer route. > **Weaker than SP-initiated sign-in.** IdP-initiated SSO cannot be tied to a browser the way SP-initiated login is, so a stolen, still-unused response can be replayed once in another browser within those minutes. Enable it only for IdPs your organizations rely on for portal launches. ## Logout and limits [#logout-and-limits] `logout(credential)` calls the `revokeSession` callback you configure, which ends the local IAM session. Upstream SAML sessions are not modified: the person stays signed in at their IdP. SAML IdP functionality (Better IAM acting as a SAML IdP for other apps) and federated single logout are outside this release; use the [OAuth/OIDC provider](/docs/federation/oauth-provider) to be an identity provider for your own apps. ## Next steps [#next-steps] - [Enterprise onboarding](/docs/federation/enterprise-onboarding): Domain verification, SSO, SCIM, and offboarding for one customer. - [SCIM inbound](/docs/federation/scim): Let the same IdP create and deactivate the accounts. # SCIM outbound (/docs/federation/scim-outbound) > Push a tenant's members and groups to downstream SaaS applications over SCIM 2.0, with previews, account adoption, and automatic deprovisioning. SCIM (System for Cross-domain Identity Management) is the standard HTTP API that applications expose so that another system can create, update, and remove their user accounts. Most business SaaS tools accept it: Slack, GitHub, Zoom, Atlassian, and many more. **The problem it solves.** When Better IAM knows who belongs to an organization, the people in that organization also need accounts in the other tools they use, with the right name, email, and department. More importantly, they need to lose those accounts when they leave. Doing that by hand leaves orphaned accounts behind, which is how former employees keep access to company data. `createScimProvisioner` fixes this by acting as a SCIM client. For each downstream application you add a **target**: the application's SCIM URL and a token it issued. The provisioner then keeps that application's user directory in step with the tenant's members: it creates accounts for people who join, updates them when their details change, and deactivates or deletes them when people leave. **Who configures it.** Your team sets up the provisioner and schedules its syncs once. An organization administrator then adds targets in your admin UI or the console's App provisioning page, using a token from each application's own admin console. Your platform can also add targets itself when it launches downstream services per organization. This is the opposite direction to [SCIM inbound](/docs/federation/scim), where a customer's directory pushes people to you. ## Set up the provisioner [#set-up-the-provisioner] Create the provisioner with the host callbacks and an encryption key for the stored tokens, mount its management API, and keep it in sync both on events and on a schedule: ```ts title="provisioning.ts" import { createScimProvisioner } from 'better-iam/scim'; export const provisioner = createScimProvisioner({ ...iam.protocolHost, encryptionKey: secrets.base64Encoded32ByteKey, basePath: '/api/iam/provisioning', }); iam.useProtocol(provisioner); // serves the JSON management API provisioner.subscribe(iam.events); // sync after member changes setInterval(() => void provisioner.syncAll(), 15 * 60_000).unref(); // and on a schedule ``` ## Add a target [#add-a-target] A target is one downstream application for one organization. Adding it needs only the application's SCIM URL and the token it issued; nothing is sent until the next sync: ```ts const target = await provisioner.createTarget(credential, { tenantId, name: 'Slack', baseUrl: 'https://api.slack.com/scim/v2', token: slackScimToken, groupIds: [engineeringGroupId], // omit for every member attributeMapping: { department: 'department', title: 'jobTitle' }, }); ``` `createTarget` returns the target summary: its settings, `provisioned` (the number of linked, active downstream accounts), `tokenUpdatedAt`, and `lastRun` once a sync has run. The token is never returned. ## What the application receives [#what-the-application-receives] Each target receives every active user member in scope as a SCIM user. With the mapping above, a member with the identity attributes `department: "Research"` and `jobTitle: "Principal Engineer"` is sent as: ```json { "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User", "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" ], "externalId": "5d1c9f3e-7a2b-4c8d-9e0f-1a2b3c4d5e6f", "userName": "ada@acme.com", "displayName": "Ada Lovelace", "name": { "formatted": "Ada Lovelace" }, "emails": [{ "value": "ada@acme.com", "type": "work", "primary": true }], "title": "Principal Engineer", "active": true, "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { "department": "Research" } } ``` `externalId` is the Better IAM identity ID, which never changes, and `userName` plus the primary work email are the member's email address. **Who is in scope.** A member is provisioned when they are a user (not a service account), active, have an email address, are not past their expiry, and, when `groupIds` is set, belong to one of those groups. Temporary and access-package memberships stop counting at their end, before the purge worker removes them. ## How a sync works [#how-a-sync-works] * **Adoption.** A first sync adopts an existing downstream user with the same `externalId` or `userName` instead of creating a duplicate, so turning provisioning on for an app people already use is safe. If several downstream users match, that member fails and is reported. * **Updates.** Later runs replace users whose data changed and skip unchanged ones without calling the service. A user the service lost (`404`) is recreated. * **Leaving scope.** Members who are disabled, deleted, past their expiry, or no longer in the scoped groups are deactivated downstream (`PATCH active: false`), or deleted when `deprovision: 'delete'`. * **Returning.** The same downstream account is reactivated when they come back. * **Everything off.** A disabled target, or a suspended tenant, deprovisions everyone at the next run. A failure is retried by the next run and never stops the rest of the run. One run per target executes at a time; concurrent requests for the same target share the running one. ## Keep targets current [#keep-targets-current] Three calls run syncs, for different situations: | Call | Use it for | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `subscribe(iam.events, { debounceMs?, onError? })` | Near-real-time updates. It schedules a sync of the tenant's targets after identity, group, access-package, tenant, SCIM, and invitation events, waiting `debounceMs` (default 2 seconds) so a burst of changes becomes one run. It ignores the provisioner's own events and returns an unsubscribe function. | | `syncAll({ tenantId? })` | A periodic safety net that also catches changes without events, such as expiries. A deployment operation for schedulers: it takes no credential, so never expose it over HTTP. See [Protocol jobs](/docs/operations/jobs#protocol-jobs). | | `syncTarget(credential, { tenantId, targetId })` | A "Sync now" button. Runs one target on demand (`iam:scim:targets:sync`) and returns the run report. | Because inbound SCIM changes are IAM events too, a person deactivated by their company's directory is deactivated in every connected application at the next dispatch of audit events plus the debounce: within about a minute when you dispatch every minute. Subscriptions fire only when `iam.events.dispatch()` runs in the same process; see [Protocol jobs](/docs/operations/jobs#protocol-jobs) and [Offboarding end to end](/docs/federation/enterprise-onboarding#offboarding-end-to-end). ## Preview a sync [#preview-a-sync] Before turning on a new target, or after changing its scope, check what would happen: ```ts const preview = await provisioner.previewTarget(credential, { tenantId, targetId: target.id }); // preview.counts: { create, adopt, update, reactivate, deactivate, delete, unchanged } // preview.changes: [{ identityId, email, action }], the first 200 // preview.errors: lookups that failed ``` `previewTarget` (`iam:scim:targets:read`) shows what the next sync would do without doing it. It sends only the read-only lookups that detect adoptable accounts, writes nothing to the store, and leaves `lastRun` untouched. ## Push groups [#push-groups] Some applications grant access by group rather than per user. With `pushGroups: true`, the target's `groupIds` groups are maintained downstream as SCIM groups: * `externalId` is the group ID, `displayName` the group name, and `members` the provisioned users of that group. * Groups are adopted by `externalId` or `displayName`, and replaced only when the name or membership changes. * They are deleted downstream when they leave `groupIds`, are deleted, or `pushGroups` is turned off. `pushGroups` needs at least one group in `groupIds`. `lastRun.groups` counts created, updated, deleted, and unchanged groups, and group failures are reported with `groupId`. ## Read the run report [#read-the-run-report] Each target keeps its latest run in `lastRun`: ## Manage targets [#manage-targets] | Method | Permission on `scim/outbound/{targetId}` | What it does | | -------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `createTarget(credential, input)` | `iam:scim:targets:create` | Adds an application. Audited as `iam:scim:CreateTarget`. | | `listTargets(credential, { tenantId })` | `iam:scim:targets:read` | The tenant's targets with provisioned counts and last runs. | | `getTarget(credential, { tenantId, targetId })` | `iam:scim:targets:read` | One target. | | `updateTarget(credential, { tenantId, targetId, ...changes })` | `iam:scim:targets:update` | Changes settings; `token` replaces the stored token. Scope changes apply at the next sync. Audited as `iam:scim:UpdateTarget`. | | `deleteTarget(credential, { tenantId, targetId })` | `iam:scim:targets:delete` | Removes the target and its links. Downstream accounts are left as they are. Audited as `iam:scim:DeleteTarget`. | | `previewTarget(credential, { tenantId, targetId })` | `iam:scim:targets:read` | The plan of the next sync. | | `syncTarget(credential, { tenantId, targetId })` | `iam:scim:targets:sync` | Runs one target now. Audited as `iam:scim:SyncTarget`. | > **Deleting a target does not deprovision.** Deleting a target stops syncing and forgets the links, but leaves the downstream accounts as they are. To remove everyone first, set `enabled: false`, let a sync run, then delete the target. `provisioner.handler` serves the same operations as a JSON API for browsers: `POST {basePath}/targets/{list,get,create,update,delete,sync,preview}` with the caller's IAM cookie or bearer token. It applies the IAM API's CSRF rule (`X-Better-IAM: 1` and a JSON body of at most 64 KiB) and answers with the `{ data }` / `{ error }` envelope. Mounted under the IAM path, the [typed client](/docs/frameworks/client) reaches it through `$request`: ```ts title="Browser" const targets = await client.$request('provisioning/targets/list', { tenantId }); ``` The console's App provisioning page works this way. ## Rotate the encryption key [#rotate-the-encryption-key] To replace `encryptionKey`, deploy the new key with the old one in `previousEncryptionKeys`, then re-seal the stored tokens: ```ts const provisioner = createScimProvisioner({ ...iam.protocolHost, encryptionKey: secrets.newProvisioningKey, previousEncryptionKeys: [secrets.oldProvisioningKey], }); const { resealed, current, unreadable } = await provisioner.rotateKeys(); ``` `rotateKeys()` is a deployment operation (not served over HTTP) and is idempotent. Once `unreadable` is 0 and every instance runs with the new key, remove the old one. ## Security [#security] * The downstream bearer token is encrypted with `encryptionKey` (AES-256-GCM), is write-only, and is replaced through `updateTarget({ token })`. * Downstream calls require HTTPS (`allowInsecureLocalhost` permits loopback HTTP for development), time out after `timeoutMs` (10 seconds), and do not follow redirects, so the token is never sent anywhere but the configured URL. * Target management is authorized per target and audited as `iam:scim:{Create,Update,Delete,Sync}Target`. ## Next steps [#next-steps] - [SCIM inbound](/docs/federation/scim): Let a customer's directory push people to you. - [Access packages](/docs/guides/privileged-access/access-packages): Time-bound group access that the provisioner deprovisions at its end. # SCIM inbound (/docs/federation/scim) > Let an organization's directory create, update, deactivate, and delete its people and groups through connection-scoped SCIM 2.0 endpoints. SCIM (System for Cross-domain Identity Management, RFC 7643 and RFC 7644) is a standard REST API for managing user accounts and groups from another system. Identity providers (IdPs) such as Okta and Microsoft Entra ID use it to keep the applications a company uses in step with the company's directory. **The problem it solves.** Single sign-on creates an account only when someone first signs in, and it never removes one. Without provisioning, a company's IT team has to create accounts by hand before people start, update them when people change teams, and remember to remove them when people leave. The last step is the one that gets missed, and a former employee keeps access. With SCIM, the company's directory does all three automatically: it creates the account when someone joins, updates their name, department, and manager as they change, and deactivates them the moment they leave. `createScimService` makes Better IAM a SCIM 2.0 service provider (the side that receives these calls). Each organization gets its own **connection** with its own bearer token. A connection belongs to exactly one tenant, and the users and groups it provisions are isolated from every other connection. **Who configures it.** Your team mounts the service once and builds a provisioning screen (or uses the console). An organization's administrator, usually the customer's IT team, then creates a connection on that screen, pastes its URL and token into their IdP's provisioning settings, and maps directory groups to roles. Going the other way, pushing your members into SaaS applications, is [SCIM outbound](/docs/federation/scim-outbound). ## Set up the service [#set-up-the-service] Create the service with the host callbacks, choose where its two sets of routes live, and mount it. `mapAttributes` here copies a job title and department from the directory into identity attributes: ```ts title="iam.ts" import { createScimService } from 'better-iam/scim'; export const scim = createScimService({ ...iam.protocolHost, basePath: '/api/iam/scim/v2', adminBasePath: '/api/iam/scim-admin', mapAttributes: ({ title, enterprise }) => ({ ...(title ? { title } : {}), ...(typeof enterprise?.department === 'string' ? { department: enterprise.department } : {}), }), }); iam.useProtocol(scim); ``` Mount both paths under the IAM handler's path, and forward `PUT`, `PATCH`, and `DELETE` as well as `GET` and `POST` to `iam.handler`, so identity providers reach the protocol endpoints. See [Protocol mounts](/docs/operations/deployment/protocol-mounts). ## Create a connection [#create-a-connection] A connection is one directory's credential for one organization. Create one per IdP that provisions into a tenant: ```ts const connection = await scim.createConnection(credential, { tenantId, name: 'Okta provisioning', expiresIn: 180 * 86400, // optional, seconds }); // connection.path: '/api/iam/scim/v2/{connection.id}' // connection.token: shown once ``` `createConnection` requires `iam:scim:connections:create`. It returns the bearer token once, its expiry, and a connection-specific base path. Configure that path (with your origin) and the token in the provisioning client. The token is stored only as a hash, lasts 90 days by default (60 seconds to one year), and is restricted to exactly one connection and tenant. Put a reminder on the expiry date, or rotate earlier with `rotateToken`. **Okta:** In the app integration's provisioning settings, set: * **SCIM connector base URL**: `https://identity.example/api/iam/scim/v2/{connectionId}` * **Unique identifier field for users**: `userName` * **Authentication Mode**: HTTP Header, with the connection token Then enable the provisioning actions you want (create, update, deactivate) and push the groups whose roles you [map](#groups-and-role-mappings). **Entra ID:** In the enterprise application's **Provisioning** settings, choose automatic provisioning and set: * **Tenant URL**: `https://identity.example/api/iam/scim/v2/{connectionId}` * **Secret Token**: the connection token Entra ID's dialect is handled: case-insensitive operation names, `"True"` and `"False"` strings for booleans, bare-ID manager values, and member removal by value. ## How provisioning works [#how-provisioning-works] The IdP first searches for the person, creates them if they are missing, keeps them updated, and deactivates or deletes them when they leave. Every request runs in one transaction: the mutation and its audit event commit together. The connection's `lastUsedAt` is recorded at most once a minute, so you can see whether an IdP is still calling. ## Users [#users] A SCIM user becomes a Better IAM identity. Users support create, retrieve, list, replace, PATCH, and delete. These fields are stored and returned as provisioned: | Schema | Fields | | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `urn:ietf:params:scim:schemas:core:2.0:User` | `userName` (required), `displayName`, `externalId`, `active`, `name` (`formatted`, `familyName`, `givenName`, `middleName`, `honorificPrefix`, `honorificSuffix`), `emails` (up to 20, one primary), `title` | | `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User` | `employeeNumber`, `costCenter`, `organization`, `division`, `department`, `manager` | The second row is the **enterprise user extension**, a standard add-on schema for HR-style fields. Supply `mapAttributes` to turn any of these into the identity attributes the product declares (`permissions.identityAttributes`). `validateIdentityAttributes` from `iam.protocolHost` validates the mapping, so a directory can drive `principal.department`-style [policy conditions](/docs/guides/authorization/conditions). * **Uniqueness.** `userName` is unique per connection, ignoring case. The identity's email is the primary email (or the first one, or `userName` when it is an email address). * **No takeover.** A provisioning request cannot take over an existing local account based on email. An email that belongs to another identity in the tenant fails with `409 uniqueness`. * **Deactivation.** `active: false` disables the local identity and revokes its user, API, and assumed-role sessions, so access ends at once. `active: true` enables it again. * **Deletion.** `DELETE` disables the identity, revokes its sessions, and removes it from the connection's groups. The disabled identity is preserved as a historical principal, so audit history still names who did what. * **Protected identities.** Protected owners and root administrators cannot be modified by SCIM (`403 mutability`), so a directory mistake cannot lock you out of a tenant. * **Administrator deletions win.** An identity an administrator deleted can no longer be updated through SCIM (`403 mutability`): SCIM never reactivates it or adds it back to groups, while a SCIM delete of it still succeeds. ### Manager mapping [#manager-mapping] The enterprise extension's `manager.value` becomes the person's `Identity.managerId` (`mapManager`, on by default), so [approvals](/docs/guides/privileged-access/elevation) and manager-review certification campaigns route to the directory's reporting line without anyone maintaining it twice. * The value may be the manager's SCIM ID, `externalId`, or `userName` within the same connection. A reference naming the person themself is ignored. * A report provisioned before their manager is linked once the manager arrives, so the order the IdP sends people in does not matter. * A manager that would close a reporting loop, or a deleted one, is skipped rather than refused. * SCIM clears only a manager it set itself, never one an administrator chose. Deleting the manager through SCIM releases the reports it linked. ## Groups and role mappings [#groups-and-role-mappings] Directory groups ("Engineering", "Finance") usually decide who should have which access. Groups support the same operations as users. Each SCIM group maintains a local IAM group with its `displayName` as the name. Group membership accepts users of the same connection only (up to 1000 member references per request); nested groups are not supported. A directory group carries no authority by itself: the IdP decides who is in "Engineering", but an administrator of your product decides what "Engineering" may do, by mapping the group to roles: ```ts await scim.setRoleMappings(credential, { tenantId, connectionId: connection.id, groupId: scimGroupId, // the SCIM group ID, from listGroups roleIds: [engineeringRoleId], }); ``` * The caller needs `iam:scim:mappings:update` on the connection and `iam:bindings:create` on every role, so nobody can map a role they could not grant directly. At most 100 roles; protected roles are refused. * The host's transactional `syncRoleMappings` callback creates the group bindings with the administrator's credential. Subsequent membership updates inherit that configured binding and its original delegated authority, so joining "Engineering" in the directory grants the engineering role here. * A SCIM token cannot create policies, roles, boundaries, or trust relationships. It only moves people in and out of groups an administrator has already mapped. * Deleting a SCIM group removes the bindings its mapping created and the local group. Revoking the connection removes all of its mapped bindings. `scim.listGroups(credential, { tenantId, connectionId })` (`iam:scim:connections:read`) lists the groups an identity provider pushed through a connection, with their SCIM ID, local `groupId`, member counts, and mapped `roleIds`. Use it to build the "map directory groups to roles" screen. ## Filtering, sorting, and paging [#filtering-sorting-and-paging] Before creating anyone, IdPs search for existing users and groups, usually by `userName` or `externalId`. Filtering implements the full RFC 7644 grammar, so any IdP's queries work: ```http GET /Users?filter=userName eq "ada@acme.com" GET /Users?filter=emails[type eq "work" and primary eq true] GET /Users?filter=name.familyName sw "Lov" and not (active eq false) GET /Users?filter=urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department eq "Research" GET /Users?filter=meta.lastModified gt "2026-09-01T00:00:00Z"&sortBy=name.familyName&count=50 ``` * `and`, `or`, `not (...)`, parentheses, value paths (filters inside a multi-valued attribute, like `emails[...]`), sub-attributes, schema-qualified names, and the operators `eq ne co sw ew pr gt ge lt le` (equals, not equals, contains, starts with, ends with, present, and comparisons). * Filters evaluate against the rendered resource, so any returned attribute is filterable. A multi-valued attribute matches when any value does. * IDs, external IDs, and member values are case-exact; other strings compare case-insensitively. `active` and `primary` require booleans, and booleans allow only `eq` and `ne`. * Filters are bounded: 2048 characters, 64 comparisons, and 16 levels of nesting. Anything outside the grammar fails with `invalidFilter`, never an unfiltered result. * `sortBy` and `sortOrder` sort by any attribute (multi-valued attributes by their primary value; unassigned values last). * `attributes` and `excludedAttributes` choose which fields a response includes; `id` and `schemas` are always returned. * `POST /Users/.search` and `POST /Groups/.search` accept the same query as a `SearchRequest` body, for queries too long for a URL. * Pagination uses a one-based `startIndex` and `count` (default 100), with at most 200 results per response. ## PATCH [#patch] `PATCH` changes part of a resource instead of replacing all of it. IdPs use it for most updates, such as deactivating a user or adding one member to a group: ```json title="PATCH /Users/{id}" { "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [ { "op": "replace", "path": "name.givenName", "value": "Ada" }, { "op": "replace", "path": "emails[type eq \"work\"].value", "value": "ada@acme.com" }, { "op": "replace", "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value", "value": "u-42" }, { "op": "Replace", "value": { "active": "False" } } ] } ``` * Pathless `add` and `replace`, including schema-qualified keys and whole extension objects. * Sub-attribute paths (`name.givenName`, `urn:…:enterprise:2.0:User:manager.value`) and value paths with optional sub-attributes (`emails[type eq "work"].value`, `members[value eq "…"]`). * Removal of listed members: `{ "op": "remove", "path": "members", "value": [{ "value": "…" }] }`. * An `add` to a value path that matches nothing creates the entry, seeded from the filter's `eq` comparisons. A `replace` that matches nothing returns `noTarget`. * Operation names are case-insensitive, and `"True"`/`"False"` strings are accepted for boolean attributes, matching Microsoft Entra ID. * Every PATCH (1 to 100 operations) is applied to a copy and saved through the same validation as `PUT`, so a failing operation leaves the resource unchanged. Required attributes cannot be removed. ## Bulk [#bulk] `Bulk` sends many operations in one HTTP request, which IdPs use for large initial imports. `POST {connection}/Bulk` accepts up to 100 operations in one `BulkRequest` of at most 1 MiB: ```json title="POST /Bulk" { "schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkRequest"], "failOnErrors": 5, "Operations": [ { "method": "POST", "path": "/Groups", "bulkId": "eng", "data": { "displayName": "Engineering", "members": [{ "value": "bulkId:ada" }] } }, { "method": "POST", "path": "/Users", "bulkId": "ada", "data": { "userName": "ada@acme.com" } }, { "method": "PATCH", "path": "/Users/2819c223", "version": "W/\"3\"", "data": { "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [{ "op": "replace", "value": { "active": false } }] } } ] } ``` * Each operation commits in its own transaction, so a failed operation never leaves partial writes. * `bulkId:` references let one operation point at a resource created by another in the same request, regardless of order: forward references (like the group above, which names a user created after it) are deferred. Cycles and references to failed operations return `409`, unknown references `400`. * `POST` operations need a `bulkId`. `failOnErrors` stops processing after that many failures, and `version` maps to `If-Match`. ## Versions and ETags [#versions-and-etags] Every resource has a weak version (`meta.version`, for example `W/"3"`), also sent as the `ETag` header. It prevents lost updates when two changes race. `GET` honours `If-None-Match` (`304`, nothing changed), and writes honour `If-Match` (`412 invalidVers` when the resource changed since it was read). ## Manage connections [#manage-connections] These methods back the provisioning screen of your admin UI. They take an administrator's credential, never the SCIM token, and check the listed permission in the connection's tenant: | Method | Permission | What it does and when to use it | | --------------------------------------------------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `createConnection(credential, { tenantId, name, expiresIn? })` | `iam:scim:connections:create` | Starts provisioning for an organization. Returns `{ id, token, expiresAt, path }`; the token is shown once. | | `listConnections(credential, { tenantId })` | `iam:scim:connections:read` | Shows each connection's name, path, expiry, revocation, creation, last-use and rotation times, provisioned user and group counts, and role mappings. Never the token. Use it for a status page. | | `rotateToken(credential, { tenantId, connectionId, expiresIn? })` | `iam:scim:credentials:create` | Issues a replacement token once, before expiry or after a leak. The previous token stops working immediately; provisioned users, groups, and mappings are kept. | | `revokeConnection(credential, { tenantId, connectionId })` | `iam:scim:connections:delete` | Ends provisioning: invalidates the token immediately and removes the connection's configured role bindings. | | `listGroups(credential, { tenantId, connectionId })` | `iam:scim:connections:read` | Lists pushed groups with member counts and mapped roles. | | `setRoleMappings(credential, { tenantId, connectionId, groupId, roleIds })` | `iam:scim:mappings:update` | Maps one SCIM group to roles. | The same administration is available as JSON routes for browser consoles: `handler` serves `POST {adminBasePath}/connections/{list,create,rotate,revoke,groups,mappings}`. The routes authenticate the caller's session cookie or bearer token like any IAM call. They require `X-Better-IAM: 1` and a JSON body of at most 64 KiB, refuse a mismatched `Origin`, and answer `{ data }` or `{ error: { code, message } }`. ## Discovery and limits [#discovery-and-limits] IdPs read `ServiceProviderConfig`, `ResourceTypes`, and `Schemas` to learn what the server supports, including the enterprise user extension. | Limit | Value | | ----------------------- | ----------------------------------------------------- | | Request body | 1 MiB, `application/scim+json` or `application/json` | | Results per page | 100 by default, at most 200 | | Bulk operations | 100 per request | | PATCH operations | 100 per request | | Group member references | 1000 per request | | Filters | 2048 characters, 64 comparisons, 16 levels of nesting | Password changes, nested groups, and extension schemas other than the enterprise user extension are not enabled. ## Audit [#audit] Mutation records and audit events commit together. Administrative events (`iam:scim:CreateConnection`, `iam:scim:RotateToken`, `iam:scim:RevokeConnection`, `iam:scim:SetRoleMappings`) retain the authenticated administrator. Provisioning events (`iam:scim:CreateUser`, `iam:scim:UpdateUser`, `iam:scim:DeleteUser`, `iam:scim:CreateGroup`, `iam:scim:UpdateGroup`, `iam:scim:DeleteGroup`) identify the SCIM connection as the actor (`scim:{connectionId}`). ## Next steps [#next-steps] - [SCIM outbound](/docs/federation/scim-outbound): Push the members your directory provisioned on to SaaS applications. - [Enterprise onboarding](/docs/federation/enterprise-onboarding): SSO, SCIM, and offboarding for one customer, end to end. # Shared Signals (/docs/federation/shared-signals) > Push signed CAEP and RISC security events about a tenant's people to SIEMs, applications, and partner IdPs as sessions end and accounts change. Revoking a session in Better IAM ends it here, but other systems keep their own state. An app that turned an ID token into its own cookie still thinks the person is signed in. A partner identity provider (IdP) still trusts the account. The company's security team never hears, in their security information and event management system (SIEM), that a password was reset. Without a signal, those systems find out hours later, or never. The **OpenID Shared Signals Framework (SSF)** is the standard for sending such signals between systems. It defines two sets of event types: * **CAEP** (Continuous Access Evaluation Profile) covers events that should make a receiver re-check an active session right now, such as "this session was revoked" or "this person's credentials changed". * **RISC** (Risk Incident Sharing and Coordination) covers account-level events, such as "this account was disabled", "deleted", or "its email address changed". Each event travels as a **Security Event Token (SET, RFC 8417)**: a small JSON Web Token (JWT) signed by the sender, so the receiver can verify where it came from. Better IAM pushes SETs to the receiver's HTTPS endpoint (push delivery, RFC 8935) and retries until they are accepted. `createSharedSignalsTransmitter` makes Better IAM an SSF **transmitter**. It turns IAM activity (sessions revoked, credentials changed, identifiers changed, accounts disabled or deleted) into signed SETs and pushes them to each tenant's receivers. The activity comes from the same audit events that feed the audit chain and webhooks. **Who configures it.** Your team sets up the transmitter and its delivery job once. Tenant administrators (often the customer's IT or security team) then add **streams**, one per receiver, with the endpoint and credentials the receiver's operator gives them. ## Set up the transmitter [#set-up-the-transmitter] Create the transmitter with the host callbacks and a signing key, connect it to IAM's events, and schedule its retries. The example also creates a first stream, which an administrator would normally do from your admin UI: ```ts title="signals.ts" import { createSharedSignalsTransmitter, sharedSignalEvents } from 'better-iam/oauth'; export const signals = createSharedSignalsTransmitter({ ...iam.protocolHost, issuer: 'https://id.example.com/oidc', // usually the OAuth issuer; its /jwks publishes the public keys jwks: secrets.privateSigningJwks, encryptionKey: secrets.base64Encoded32ByteKey, }); signals.subscribe(iam.events); // publish as IAM events are dispatched setInterval(() => void signals.dispatch(), 60_000).unref(); // retries await signals.createStream(credential, { tenantId, name: 'Acme SIEM', endpointUrl: 'https://siem.acme.com/ssf/events', authorization: 'Bearer receiver-issued-token', events: [sharedSignalEvents.sessionRevoked, sharedSignalEvents.credentialChange], subjectFormat: 'email', }); ``` When the transmitter shares the OAuth provider's issuer and keys, the provider's `/jwks` already publishes the public half. Otherwise publish it yourself at `jwksUri`. Three functions move events: * `subscribe(iam.events, { onError? })` listens to IAM events, turns matching ones into SETs, and delivers them at once. Call it at startup. It returns an unsubscribe function. * `dispatch({ limit? })` sends deliveries that are due, including retries (at most 200 per call by default), and prunes delivered records older than a week. Run it on an interval; see [Protocol jobs](/docs/operations/jobs#protocol-jobs). * `publish(auditEvent)` queues SETs for one audit event yourself, for example when replaying events, and returns the number of streams addressed. ### Serve the transmitter metadata [#serve-the-transmitter-metadata] Receivers learn about a transmitter from a standard metadata document. `signals.handler(request)` serves it at `/.well-known/ssf-configuration{issuer path}` and returns `undefined` for every other request. Put it in front of your other handlers: ```ts export async function handle(request: Request): Promise { return signals.handler(request) ?? (await iam.handler(request)); } ``` The document names the `issuer`, the `jwks_uri`, push delivery (`urn:ietf:rfc:8935`) as the only delivery method, `spec_version: "1_0"`, and `default_subjects: "ALL"`. ## Event mapping [#event-mapping] The transmitter watches for these audit actions and turns each into one standard event. Other activity, including SCIM provisioning changes, produces no event. | IAM activity | Audit actions | Event | | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | Sign-out of a session, "sign out other devices", administrator session revocation, tenant-wide revocation | `auth:session:revoke`, `auth:session:revoke-others`, `identity:revoke-sessions`, `tenant:revoke-sessions` | CAEP `session-revoked` | | Password changed or reset | `auth:password:change`, `auth:password:reset` | CAEP `credential-change` (`credential_type: password`, `change_type: update`) | | Authenticator app added or removed | `auth:mfa:enable`, `auth:mfa:disable` | CAEP `credential-change` (`credential_type: app`, `change_type: create` or `delete`) | | Passkey added or removed | `auth:passkey:create`, `auth:passkey:delete` | CAEP `credential-change` (`credential_type: fido2-platform`, `change_type: create` or `delete`) | | Email address changed, by the person or an administrator | `auth:email:change`, `identity:email-change` | RISC `identifier-changed` | | Offboarding, scheduled expiry | `identity:offboard`, `identity:expire` | RISC `account-disabled` | | Deletion | `identity:delete` | RISC `account-purged` | Only allowed (successful) actions produce events. The `sharedSignalEvents` export names the five event type URIs, for a stream's `events` list: `sessionRevoked`, `credentialChange`, `identifierChanged`, `accountDisabled`, and `accountPurged`. What receivers typically do with them: end their own session on `session-revoked`, ask for a fresh sign-in or flag the account on `credential-change`, update their copy of the email on `identifier-changed`, and block or remove the account on `account-disabled` and `account-purged`. ## What receivers get [#what-receivers-get] Receivers verify the signature with your public keys, then read who the event is about and what happened. Each SET is signed with the first private key that has an `alg`, with `typ: secevent+jwt` and the key's `kid`. Decoded, a session revocation looks like this: ```json { "iss": "https://id.example.com/oidc", "aud": "https://siem.acme.com/ssf/events", "jti": "0b6f5e1c-4d0a-4a8b-9f3e-7c2d1e5a9b40", "iat": 1790000000, "txn": "audit-event-id", "sub_id": { "format": "iss_sub", "iss": "https://id.example.com/oidc", "sub": "identity-id" }, "events": { "https://schemas.openid.net/secevent/caep/event-type/session-revoked": { "event_timestamp": 1790000000, "initiating_entity": "admin" } } } ``` * `aud` is the stream's `audience`, which defaults to its endpoint URL. * `txn` is the ID of the IAM audit event that caused it, so receivers can correlate with your [audit log](/docs/guides/events/audit-chain). * `sub_id` says who the event is about. It is `{ format: "iss_sub", iss, sub: identityId }` by default, or `{ format: "email", email }` when the stream asks for `subjectFormat: 'email'` and the address still exists. Tenant-wide revocation uses a `complex` subject naming the tenant. * `event_timestamp` is when the activity happened. `initiating_entity` is `user` when the person acted on their own account, otherwise `admin`. ## Streams [#streams] A stream is one receiver of one tenant's events: the customer's SIEM, one of their applications, a partner IdP. Administrators configure streams, usually from a settings screen in your admin UI; receivers cannot create or change them. | Method | Permission on `ssf/{streamId}` | What it does and when to use it | | --------------------------------------------------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `createStream(credential, input)` | `iam:ssf:streams:create` | Adds a receiver. | | `listStreams(credential, { tenantId })` | `iam:ssf:streams:read` | Lists streams with health fields: `pending` deliveries, `lastDeliveredAt`, `lastError`, and `hasAuthorization`. Use it for a status page. | | `getStream(credential, { tenantId, streamId })` | `iam:ssf:streams:read` | Reads one stream. | | `updateStream(credential, { tenantId, streamId, ...changes })` | `iam:ssf:streams:update` | Changes settings, pauses or resumes, or replaces the header. `authorization: null` removes the header. | | `deleteStream(credential, { tenantId, streamId })` | `iam:ssf:streams:delete` | Removes the stream and drops its undelivered events. | | `verifyStream(credential, { tenantId, streamId, state? })` | `iam:ssf:streams:update` | Sends an SSF verification event right away and reports whether the receiver accepted it. Use it after setup to test the endpoint, credentials, and keys in one call. | | `listDeliveries(credential, { tenantId, streamId, status?, limit? })` | `iam:ssf:streams:read` | Recent deliveries, newest first (default 50, at most 500), filterable by `pending`, `delivered`, or `failed`. Use it to debug a receiver. | Stream changes are audited as `iam:ssf:CreateStream`, `iam:ssf:UpdateStream`, and `iam:ssf:DeleteStream`. The receiver's `authorization` header is encrypted with `encryptionKey` and never returned. Endpoints must use HTTPS (`allowInsecureLocalhost` allows loopback HTTP for development). `verifyStream` sends a verification event whose `state` echoes back to the receiver, and returns `{ delivered, error?, jti }`. ## Delivery [#delivery] Receivers go offline for maintenance, so delivery is queued and retried rather than attempted once. Each delivery moves through these states: * Deliveries are `POST` requests with `Content-Type: application/secevent+jwt` and the stream's `authorization` header. Redirects are not followed. * They expect `202`, and also accept `200` and `204`. Any other answer records `HTTP` and the status, with the receiver's `err` and `description` when it sends them, as the stream's `lastError`. * Failures retry with backoff: 30 seconds, 2 minutes, 8 minutes, about 30 minutes, then every 2 hours, for eight attempts in total. After that the delivery is `failed`. * A paused stream (`enabled: false`) keeps collecting events and delivers them after it is re-enabled. Deleting a stream drops its queue. A suspended tenant's deliveries fail and retry. ## Not offered [#not-offered] Receiver-driven stream management (the SSF stream configuration API, where a receiver creates its own stream) and poll delivery (where a receiver fetches events) are not offered: streams are configured by administrators, and events are pushed. ## Next steps [#next-steps] - [Enterprise onboarding](/docs/federation/enterprise-onboarding#offboarding-end-to-end): How Shared Signals fits into offboarding with SCIM and back-channel logout. - [Webhooks](/docs/guides/events/webhooks): Deliver every audit event, not only security events, to your own services. # AI agents (/docs/guides/ai-agents) > Treat AI agents as accounts with a responsible sponsor, a ceiling on what they may do, and short delegated sessions when they act for a person. An AI agent that files tickets, schedules meetings, or edits documents needs credentials. Handing it a person's session means nobody can tell afterwards whether a person or the agent acted, and the agent can do everything the person can. Giving it an ordinary service account means nobody is answerable for it, and it keeps working long after the person who set it up has left. Better IAM treats agents as accounts of their own, with three safeguards: * **A sponsor.** Every agent has a person accountable for it. Its credentials work only while that sponsor is an active member of the same organization. When the sponsor leaves, offboarding hands the agent to their successor, or the agent stops until an administrator names a new sponsor. No agent outlives the person answerable for it. * **A ceiling.** The agent's `boundary` policy caps everything it does, whatever its roles say and whoever it acts for. * **Delegation.** A person can let an agent act for them, within a scope and for a limited time. The agent then opens short delegated sessions that act as the person, never with more than the person has. An agent is an identity of kind `agent`. Like a service account it holds API keys and never signs in, and like every identity it can have roles, groups, and policies. ## Register an agent [#register-an-agent] ```ts const agent = await iam.api.agents.create(admin, { tenantId, name: 'Support triage', purpose: 'Labels and routes incoming support tickets', model: 'claude-sonnet-5', provider: 'anthropic', protocols: ['mcp'], sponsorId: alice.id, // defaults to the caller when the caller is a person of the organization boundary: { version: 1, statements: [{ effect: 'allow', actions: ['tickets:*'], resources: ['ticket/*'] }], }, }); // Keys work as for service accounts: typed `biam_key_…` tokens, bounded by the issuer's authority. const { token } = await iam.api.credentials.create(admin, { tenantId, identityId: agent.id, name: 'production', scopes: ['tickets:read', 'tickets:update'], }); ``` | Field | What it is for | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `sponsorId` | The accountable person, an active user of the same organization. | | `model`, `provider` | What the agent runs on. Policies see them as `principal.agentModel` and `principal.agentProvider`; the provider is stored in lowercase. | | `purpose`, `url` | Shown to people deciding whether to let the agent act for them. | | `protocols` | An informational list, such as `mcp` and `a2a`. | | `boundary` | The ceiling over everything the agent does. Changes apply to live sessions at once. | | `delegable` | `false` refuses new delegations and stops existing ones from being used until it is turned back on. | | `maxDelegatedSessionSeconds` | The longest delegated session the agent may hold (60 to 43200; 3600 by default). | Managing agents needs `iam:agents:create`, `:read`, `:update`, and `:delete` (deleting also needs a recent sign-in), and the plan limit `agents` caps how many an organization may register. `agents.get` reports the agent's **standing**: `ok`, `suspended`, `expired`, `deleted`, `sponsor-missing`, or `sponsor-inactive`. Anything but `ok` means every credential of the agent is refused, and keys are only issued to an agent whose standing is `ok`. People choosing an agent to delegate to see `agents.catalog`: the active, delegable agents of their organization with their purpose, model, and sponsor. ## The kill switch [#the-kill-switch] An agent that misbehaves must be stoppable by the person responsible for it, at once, without filing a ticket. A sponsor manages their agents from their own session, with no extra permission: `agents.listMine` lists them, and `agents.suspend` stops one immediately. ```ts await iam.api.agents.suspend(aliceSession, { tenantId, agentId, reason: 'Looping on the wiki' }); ``` When something goes wrong across the board, such as a compromised model provider or a runaway release, an administrator can stop every agent of the organization at once: ```ts // Every agent running on one provider's models, for example during that provider's security incident: await iam.api.agents.suspendAll(admin, { tenantId, reason: 'Provider incident', provider: 'anthropic' }); ``` `agents.suspendAll` needs `iam:agents:update` but no recent sign-in, so it works in an emergency. It suspends every active agent (or only those of one `sponsorId`, `provider`, or `model`), each exactly as `agents.suspend` would, and is audited as `agent:suspend-all` plus one `agent:suspend` per agent. Agents come back one at a time with `agents.resume`. The console's Agents page has a **Stop all agents now** button for it. A suspended agent's keys are refused, and its live delegated sessions and session tokens end immediately. Keys are kept, so `agents.resume` restores the agent as it was. A sponsor can resume only an agent they suspended themselves; lifting an administrator's suspension takes `iam:agents:update` and a recent sign-in. Deleting an agent ends its keys and sessions, revokes every delegation to it, and leaves a tombstone so audit records stay readable. ## Letting an agent act for a person [#letting-an-agent-act-for-a-person] A **delegation** lets one agent act for one person. The delegated session's identity is the person, so decisions use the person's grants, with three ceilings on top: 1. the delegation's **scope** (`scopes` as an action list, or a `policy` document); 2. the agent's **boundary**; 3. an optional scope-down `policy` the agent passes when it opens a session; and the limits of the agent key that opened the session (its scopes or session policy, and its issuer's authority), as for session tokens. So an agent can never do more than the person could, more than the person agreed to, or more than the agent and its key are allowed in general. Delegated sessions never count as a recent sign-in, never act as an owner or root administrator, cannot manage delegations or mint further credentials, and cannot obtain stateless assertions. **Granted by the person:** The person grants it from their own, recently authenticated session: ```ts const delegation = await iam.api.delegations.grant(aliceSession, { tenantId, agentId: agent.id, scopes: ['tickets:read', 'tickets:update'], expiresInSeconds: 30 * 86_400, // 5 minutes to 1 year; 30 days by default maxSessionSeconds: 900, // optional cap on each delegated session }); ``` **Requested by the agent:** The agent asks with its own key, and the request waits up to seven days. When the deployment sends email (`authentication.sendEmail`) and the person has an address, they get a `delegation-request` email (point `links.delegation` in your email templates at your approval page). Requests are rate limited per agent. ```ts const request = await iam.api.delegations.request(agentKey, { tenantId, subjectEmail: 'alice@acme.test', scopes: ['calendar:read', 'calendar:write'], reason: 'Schedule your interviews for next week', expiresInSeconds: 7 * 86_400, }); // Alice approves, possibly narrowing what was asked for (or calls delegations.deny): await iam.api.delegations.approve(aliceSession, { tenantId, delegationId: request.id, scopes: ['calendar:read'], }); ``` Approving needs a recent sign-in, like granting; denying does not. The agent polls `delegations.get` until the status changes. One pending request or active delegation may link an agent and a person at a time. To act, the agent opens a short delegated session with its key: ```ts const { token } = await iam.api.delegations.assume(agentKey, { tenantId, delegationId: delegation.id, durationSeconds: 600, sessionName: 'triage-run-42', }); // token is a `biam_dlg_…` bearer credential that acts as Alice, with the agent recorded alongside. await iam.require({ token, tenantId, action: 'tickets:update', resource: { type: 'ticket', id: 'T-1' } }); ``` A delegated session lasts from 60 seconds up to the agent's `maxDelegatedSessionSeconds` and the delegation's `maxSessionSeconds` (15 minutes or less by default), never past the delegation or the key that opened it, and at most 20 live sessions may exist per delegation. Every use re-validates the whole chain: the delegation, the person, the agent and its sponsor, and the agent's key. If any link breaks, the session is refused with `UNAUTHENTICATED`. The person, the agent's sponsor, the agent itself, or an administrator with `iam:delegations:revoke` can revoke a delegation, which ends its sessions at once. Offboarding or deleting the person revokes every delegation they gave. People see their delegations with `delegations.listMine`, agents see theirs the same way with their key, and administrators list them with `delegations.list`. Its permission, `iam:delegations:read`, also lets administrators read any delegation with `delegations.get` and `delegations.activity`. ### Confirming sensitive actions one at a time [#confirming-sensitive-actions-one-at-a-time] Some actions are too consequential to hand over for a month: deleting a document, sharing it outside the company, sending money. A delegation can hold such actions back until the person confirms each call. Pass `confirm` (action patterns) when granting, requesting, or approving. A delegated session is then refused those actions until the person approves that action on that exact resource, and the approval opens it for a few minutes. It is a human-in-the-loop check in the spirit of OpenID CIBA: the agent asks, and the person answers from wherever they are. ```ts await iam.api.delegations.grant(aliceSession, { tenantId, agentId: agent.id, scopes: ['documents:*'], confirm: ['documents:delete', 'documents:share'], }); // The agent, with its delegated session, asks before deleting: const request = await iam.api.delegations.requestConfirmation(delegated, { tenantId, action: 'documents:delete', resource: { type: 'document', id: 'q3-draft' }, reason: 'You asked me to clean up the drafts folder', validSeconds: 120, // how long the approval stays usable: 30 to 3600; 300 by default }); // Alice gets a `delegation-confirmation` email and answers from her own session: await iam.api.delegations.decideConfirmation(aliceSession, { tenantId, confirmationId: request.id, approve: true, }); // The agent polls delegations.getConfirmation, then retries the delete. ``` A request waits up to 30 minutes for a decision, and asking again for the same action and resource returns the pending request. Only actions that match `confirm` can be requested. An unconfirmed call is refused like any denied decision: callers see `ACCESS_DENIED`, and the recorded reason is `CONFIRMATION_REQUIRED`. So agents should read the delegation's `confirm` list (`delegations.get`) and ask first. `delegations.listConfirmations` lists the requests a person has to answer, or an agent's own. ### Capping what an agent spends for you [#capping-what-an-agent-spends-for-you] An agent that calls AI models on your behalf spends money on your behalf, and a loop can spend a lot of it overnight. A person can cap what an agent spends for them with `spend` on `grant`, `request`, or `approve`: `maxCostUsd`, `maxTokens`, and/or `maxRequests` per `minute`, `hour`, `day`, or `month` (at least one limit). ```ts await iam.api.delegations.grant(aliceSession, { tenantId, agentId: agent.id, scopes: ['inference:invoke', 'documents:read'], spend: { period: 'day', maxCostUsd: 5 }, }); ``` Every [model call](/docs/guides/inference) made under the delegation, or under a hand-off below it, counts against the cap like a budget. Past it, calls are refused with `BUDGET_EXCEEDED` until the window resets, and the first refusal is audited as `inference:budget-exceeded` on `delegation:{id}`. The delegation's summary reports the cap with what the current window has used (`spend.usedCostUsd`, `usedTokens`, `usedRequests`, `resetsAt`), so a consent screen can show "$0.42 of $5 today". `approve` can change a requested cap or remove it with `spend: null`, and a hand-off may carry a tighter cap of its own. The agent's own budgets and the organization's budgets apply as well. ### Handing work on to other agents [#handing-work-on-to-other-agents] Agents increasingly call other agents: an assistant asks a research agent for sources, a planner hands a step to a specialist. Sharing the assistant's own credential would hide who did what; asking the person for a second delegation every time would be tedious. When the person allows it, the agent acting for them can **hand part of its delegation on**. The other agent then acts for the same person, never with more than the handing agent had. ```ts // Alice lets her assistant hand work on, only to the research agent, one level deep: const delegation = await iam.api.delegations.grant(aliceSession, { tenantId, agentId: assistant.id, scopes: ['documents:*'], handoff: { agents: [researcher.id], depth: 1 }, // omit agents for any delegable agent; depth 1 to 3 }); // The assistant, in its delegated session, hands a narrower part on: const handoff = await iam.api.delegations.handoff( { token: assistantSession.token }, { tenantId, agentId: researcher.id, scopes: ['documents:read'], reason: 'Find sources for the report' }, ); // It passes handoff.id to the researcher (for example in an A2A message), which opens its own sessions for Alice: const research = await iam.api.delegations.assume(researcherKey, { tenantId, delegationId: handoff.id }); ``` The person sets `handoff` when granting. An agent can ask for it in `delegations.request`, but hand-offs widen what happens in the person's name, so a requested hand-off counts only when the person states `handoff` themselves when approving. A plain approval, or `null`, leaves hand-offs out. A hand-off is a delegation of its own (`requestedBy: 'handoff'`, with `parentId` and the `chain` of agents above it), so the receiving agent acts with its own key and every action is attributed to it. Its use is bounded by its own scope and every delegation above it, by the ceiling of every agent in the chain, and by the limits of the session that handed it on (that session's scope-down policy, its key's scopes, and the key's issuer). The whole chain is checked on every use: if a delegation above ends, or an agent in the chain is suspended or loses its sponsor, the hand-off stops at once. Revoking a delegation revokes the hand-offs below it, and the person's `confirm` list travels down the chain. No agent may appear twice in a chain, a delegation holds at most 20 live hand-offs, and each lasts an hour by default (`expiresInSeconds`, never past the delegation above). A hand-off is also bound to the API key the handing agent acted with: it ends when that key is revoked or expires, so revoking a compromised agent's key stops everything it handed on. Opening a session under a delegation whose parent, agent, or key has ended fails with `DELEGATION_INACTIVE`. The handing agent's own budgets count the hand-off's model calls too. Hand-offs are audited as `delegation:handoff` and show in the person's `delegations.listMine`, where they can revoke each one; when an agent revokes a hand-off from its delegated session, `revokedBy` names the agent. `principal.delegationChain` lists the agents from the person's own delegate to the acting one, so a policy can keep an agent away from some data however the work reached it: ```json title="Research agents never read HR documents, whoever hands them the work" { "effect": "deny", "actions": ["documents:read"], "resources": ["document/hr-*"], "conditions": { "ArrayContains": { "principal.delegationChain": [""] } } } ``` ### What did the agent do? [#what-did-the-agent-do] People who let an agent act for them want to see what it did. `delegations.activity` shows the person everything that happened under a delegation, newest first: its lifecycle (grant, request, approval, sessions opened, confirmations, revocation) and every allowed or denied action of the agent's sessions acting for them, including everything done under hand-offs below it. `agents.activity` shows the sponsor, or an administrator with `iam:agents:read`, everything an agent did, with its own keys and for anyone it acted for. ```ts const trail = await iam.api.delegations.activity(aliceSession, { tenantId, delegationId, limit: 50 }); ``` ## Policies for agents [#policies-for-agents] Decisions tell agents apart: `principal.kind` is `agent` for an agent's own key, `principal.delegated` is true while an agent acts for a person, and `principal.agentId`, `principal.agentSponsorId`, `principal.agentModel`, `principal.agentProvider` (lowercase), and `principal.delegationId` describe the agent involved. In a delegated session `principal.kind` is the person's (`user`), and `principal.mfa`, `principal.owner`, and `principal.rootAdmin` are always false, so test `principal.delegated` to single out agents acting for people. ```json title="Keep agents out of billing and away from deletes" { "version": 1, "statements": [ { "sid": "NoAgentsInBilling", "effect": "deny", "actions": ["billing:*"], "resources": ["*"], "conditions": { "Bool": { "principal.delegated": true } } }, { "sid": "AgentsNeverDelete", "effect": "deny", "actions": ["documents:delete"], "resources": ["*"], "conditions": { "StringEquals": { "principal.kind": "agent" } } } ] } ``` The agent keys other than `principal.delegated` are absent when no agent is involved, so guard deny statements that use them with `Exists`, as the [policy linter](/docs/guides/authorization/policies#lint) suggests (see [missing keys](/docs/guides/authorization/conditions#missing-keys)). ## Audit [#audit] Every event recorded for a delegated session names the person as the actor and carries `agentId` and `delegationId` in its `sessionContext`, covered by the [audit chain](/docs/guides/events/audit-chain). Agents record `agent:create`, `agent:suspend`, `agent:resume`, and `agent:sponsor-change`, and deleting one is recorded as `iam:agents:delete` and `identity:delete` with `kind: 'agent'`. Delegations record `delegation:grant`, `delegation:request`, `delegation:approve`, `delegation:deny`, `delegation:assume`, `delegation:revoke`, `delegation:confirmation-request`, `delegation:confirm`, and `delegation:reject`; delegations revoked because their person was offboarded or deleted write no separate `delegation:revoke`. `sts.getCallerIdentity` shows `agentId` and `delegationId` for delegated sessions. [Access analysis](/docs/reference/api/analysis) reports the risky cases: agents without an active sponsor (`agent-without-sponsor`, high), agents with full administrator access (`agent-admin`, high), delegable agents without a ceiling (`unbounded-agent`, low), delegations that allow every action (`broad-delegation`, medium), active delegations unused for the dormant window (`unused-delegation`, low), and delegations that let the agent hand work on to any agent (`open-handoff`, medium when hand-offs may go more than one level deep, otherwise low). It also flags agents refused 20 or more times in the last day, with their own keys or acting for people (`agent-denials`, medium): a sign of an agent stuck in a loop or following instructions injected into something it read. Check what it did with `agents.activity` and [suspend](#the-kill-switch) it if in doubt. The framework integrations pass delegated sessions through like any other credential. In `@better-iam/next`, `apiRoute` principals carry `session.kind: 'delegated'` with `session.agentId` and `session.delegationId`, and an agent's own key shows `identity.kind: 'agent'`. ## Consent screens in React [#consent-screens-in-react] People need a place to see which agents act for them, answer requests, and confirm held-back actions. `@better-iam/react` has the hooks for building it into your own product: * `useDelegations`: the agents acting or asking to act for the signed-in person, with `grant`, `approve`, `deny`, and `revoke`; * `useConfirmations`: actions waiting for the person's confirmation, with `approve` and `reject`; * `useAgentCatalog`: the agents a person may delegate to; * `useModels`: the AI models the caller may use. ```tsx function AgentInbox({ tenantId }: { tenantId: string }) { const { requests, approve, deny } = useDelegations({ tenantId }); const { pending, approve: confirm, reject } = useConfirmations({ tenantId }); return ( <> {requests.map((request) => ( approve(request.id)} onDeny={() => deny(request.id)} /> ))} {pending.map((item) => ( confirm(item.id)} onReject={() => reject(item.id)} /> ))} ); } ``` ## Protecting MCP servers [#protecting-mcp-servers] Agents reach tools through the Model Context Protocol. `@better-iam/mcp` (also `better-iam/mcp`) puts tool-level authorization in front of any MCP server that speaks Streamable HTTP. The gate: * authenticates the caller and answers unauthenticated requests with a `WWW-Authenticate` challenge. When you give it `metadata`, the challenge points at the server's protected resource metadata (RFC 9728), which the gate then serves; * refuses `tools/call` requests the caller may not make, as an MCP tool error so the model sees why; * removes the tools the caller may not use from `tools/list` answers, in JSON and in event streams. A tool whose `resource` is a function and that has no `listAs` is always listed, because there are no arguments to decide on yet. ```ts import { createMcpGate } from 'better-iam/mcp'; const gate = createMcpGate({ iam, tenantId: acmeTenantId, // the organization that runs this server; decisions are made there tools: { search_tickets: { action: 'tickets:read', resource: { type: 'ticket', id: 'index' } }, close_ticket: { action: 'tickets:update', resource: (args) => ({ type: 'ticket', id: String(args.id) }), listAs: { type: 'ticket', id: 'any' }, scopes: ['tickets.write'], // for OAuth callers }, ping: { public: true }, }, unlisted: 'deny', // tools without a rule are hidden and refused oauth: resourceGuard.verifier, // optional: also accept OAuth access tokens from @better-iam/oauth metadata: { resource: 'https://mcp.acme.test/mcp', authorizationServers: ['https://iam.acme.test/oauth'], }, }); export default { fetch: (request: Request) => gate(request, (forwarded, caller) => mcpServer.handle(forwarded, caller)), }; ``` `tenantId` is required and names the organization that runs the server: every decision is made in that organization, so an agent from another organization cannot grant itself access by writing policies in its own. A function `(caller) => string | undefined` picks the organization per caller, and `undefined` refuses the caller. OAuth access tokens issued for another organization are refused. Better IAM credentials (a person's session, a service account or agent key, or a delegated session) are decided by the policy engine, `action` on `resource`. OAuth access tokens are decided by the tool's `scopes`; see [MCP authorization](/docs/federation/mcp-authorization) for issuing them. Unless a tool is `public`, one without `scopes` is refused to OAuth callers and one without `action` to Better IAM credentials. Batched tool requests are answered with a JSON-RPC 400. The second argument of `next` tells the server who called. When an agent acting for a person calls a tool whose action the person [confirms one call at a time](#confirming-sensitive-actions-one-at-a-time), the gate files the confirmation request itself and answers with a tool error telling the model to call again after the approval (the request ID is in `_meta['better-iam/confirmationId']`). Agents built on any MCP client get the human-in-the-loop flow without extra code; `confirmations: false` turns it off. `createMcpAuthorizer` offers the same decisions (`authenticate`, `canCall`, `visibleTools`) for tool handlers written directly against an MCP SDK. ## Agent-to-agent (A2A) [#agent-to-agent-a2a] In the [Agent2Agent protocol](https://a2a-protocol.org), agents find each other through an **agent card**, a JSON document at `/.well-known/agent-card.json` that names the agent, its skills, and where to reach it. Anyone can publish a card that claims anything, so an agent deciding whether to hand work to another needs to know two things: is this card really from the organization it names, and is there an accountable person behind the agent? `@better-iam/a2a` answers both, and protects your own A2A servers the way the MCP gate protects tool servers. ### Attested agent cards [#attested-agent-cards] With the `a2a` server option, Better IAM signs an agent's card and vouches for it: the organization it belongs to, that a person sponsors it, whether people can delegate to it, and its model. ```ts title="iam.ts" export const iam = betterIam({ // ... a2a: { signingKeys: [cardSigningJwk], // Ed25519 or ES256 private JWKs, each with a kid jwksUrl: 'https://iam.example.com/a2a/jwks.json', // where you serve iam.a2a.jwksResponse() cardLifetimeSeconds: 3600, // 300 to 604800 }, }); ``` `agents.signCard({ tenantId, agentId, card })` signs a card. The agent can call it with its own key (an unscoped one; a key with scopes is refused), and so can its sponsor, or an administrator with `iam:agents:update`. The agent must be in good standing and have a registered `url`, and the card's `url` (and every `additionalInterfaces[].url` and `supportedInterfaces[].url`) must be on that origin. Better IAM sets `provider` to the organization's name and the agent's registered origin, ignoring whatever the card said, adds the attestation extension `urn:better-iam:a2a:attestation:v1` (the agent, its organization, that it is sponsored, whether it is delegable, its model, provider, and protocols, and when the attestation was issued and expires), and signs the canonical card (RFC 8785) as a detached JWS. Each signature is audited as `agent:card-sign`. `createCardAttestor` keeps a served card signed: it signs on first use, signs again when less than a fifth of the attestation's lifetime is left, and keeps serving the previous card if signing fails while that card is still valid. ```ts import { createCardAttestor } from '@better-iam/a2a'; const card = createCardAttestor({ card: { name: 'Triage', url: 'https://triage.example.com/a2a', skills: [/* ... */] }, sign: (card) => iam.api.agents.signCard({ token: process.env.AGENT_KEY }, { tenantId, agentId, card }), }); ``` ### The agent directory [#the-agent-directory] Before an agent can hand work to a helper, it has to find one. Every card an agent has signed is also its entry in the organization's directory: `agents.directory` lists the current attested cards of the tenant's agents in good standing, optionally only those offering a `skill` (by skill ID or tag) or speaking a `protocol`. Any credential of the tenant can read it: a person, an agent's key, or an agent acting for someone and looking for a helper. Entries leave the directory when their attestation expires or the agent is suspended or deleted, and each entry's `card` is the signed card itself, ready for `verifyAgentCard`. ```ts const [translator] = await iam.api.agents.directory(delegatedSession, { tenantId, skill: 'translate' }); // { agentId, name, card, attestation, expiresAt } ``` ### Verifying other agents [#verifying-other-agents] Before delegating work, an agent checks the other side's card: ```ts import { discoverAgent } from '@better-iam/a2a'; const { card, attestation } = await discoverAgent('https://triage.example.com', { // Each trusted Better IAM deployment (its issuer: base URL plus API path), with where its card keys are published: trustedIssuers: { 'https://iam.example.com/api/iam': 'https://iam.example.com/a2a/jwks.json' }, tenantId: 'acme', // optional: only agents of this organization }); ``` A deployment's issuer is its base URL plus the API path (`https://iam.example.com/api/iam` for `baseURL: 'https://iam.example.com'` and the default `basePath`), unless it sets `a2a.issuer`. `trustedIssuers` binds each deployment's keys to the issuer a card names, so a key trusted for one deployment can never vouch for a card claiming to come from another; use it whenever you trust more than one deployment. (`keys` and `trustedJwksUrls` are the simpler forms.) Key sets are fetched again, at most every 30 seconds, when a signature names a key that is not known yet, so key rotations need no restart. `discoverAgent` fetches the card without following redirects, with a size limit and a timeout, and requires its `url` to be on the origin it came from. `verifyAgentCard(card, options)` accepts only cards signed by a trusted key with exactly one current attestation, and checks `issuers`, `tenantId`, and `origin` when you pass them. Every failure throws `AgentCardError` with a `reason`. ### Protecting an A2A server [#protecting-an-a2a-server] `createA2aGate` sits in front of an A2A JSON-RPC server: ```ts import { createA2aGate } from '@better-iam/a2a'; const gate = createA2aGate({ iam, tenantId: acmeTenantId, // the organization that runs this server, as for the MCP gate card, message: { action: 'triage:use', resource: { type: 'agent', id: 'triage' } }, skills: { summarize: { action: 'triage:use', scopes: ['triage'] }, escalate: { action: 'tickets:escalate', resource: { type: 'queue', id: 'support' } }, }, taskAdminAction: 'triage:operate', }); export default { fetch: (request: Request) => gate(request, (forwarded, caller) => a2aHandler(forwarded, caller)) }; ``` * The card is served without authentication; everything else needs a Better IAM credential (a person's session, an agent key, or a delegated session) or, with `oauth`, an access token decided by each rule's `scopes`. * `message/send` and `message/stream` are decided by the skill the message names (`metadata.skillId`), or by the `message` rule. A refusal is JSON-RPC error `-32050` with HTTP 403. Skills without a rule are refused unless `unlistedSkills: 'message'`, and a request naming two different skills is refused as ambiguous. * Tasks are private to the caller who started them: anyone else gets "task not found" (`-32001`) when reading, cancelling, or continuing one, or naming it in `referenceTaskIds`, unless they are allowed `taskAdminAction`. Conversations (`contextId`) are private too: a context counts as the caller's only once the server's answers named it for them, and any other is refused (`-32602`). Pass `tasks` to share task and conversation ownership between server instances. * The authenticated extended card lists only the skills the caller may use. * An agent acting for a person whose delegation [holds the action back](#confirming-sensitive-actions-one-at-a-time) gets `-32051` with `error.data.confirmationId`; the person has been asked, and the retry passes once they approve. * Only `POST` is accepted (405 otherwise, unless `otherHttpMethods: 'allow'`); other JSON-RPC methods (unless `otherMethods: 'allow'`) and batches are refused; unexpected errors answer `-32603` with HTTP 500 after your `onError`. `createA2aAuthorizer` offers the same decisions for servers written directly with an A2A SDK. ## Next steps [#next-steps] - [Model access and budgets](/docs/guides/inference): Which models an agent may call, what it may spend, and a gateway that keeps provider keys away from it. - [Agents API](/docs/reference/api/agents): Every agents method, with permissions and errors. - [Delegations API](/docs/reference/api/delegations): Granting, requesting, assuming, and revoking delegations. # Billing and spend (/docs/guides/billing) > Usage meters, rate cards, and spend by person, team, department, project and organization, with budgets, plans and subscriptions, and Stripe-style invoicing. At the end of every month someone asks what the organization spent, and the next question is always who spent it. The answer depends on things an IAM system already knows: which organization and project a request ran in, who made it, which teams that person belongs to, which department they report into, and which agent acted for them. Billing puts usage and money next to that directory, so finance sees the account's total, a team lead sees their team, a department head sees their department, and everyone sees their own spend, and the numbers add up. ## The model [#the-model] * **Meters** name what is billed: API calls, seat-days, GB-days, CI builds, AI inference. The root tenant defines **platform meters** billed to every organization; an organization or project can define **chargeback meters** for its own subtree, which show in its spend but never on a platform statement. * A **rate card** prices each meter: per unit, graduated or volume tiers, packages, a free allowance, and negotiated prices for individual organizations. * A **billing account** is an organization, or a tenant below it with a billing profile of its own. Each meter's month total is priced once per account and then shared out to the usage that produced it by quantity (for active-user meters, equally per person), so every breakdown adds up to the account's charges. ## Meters and prices [#meters-and-prices] ```ts await iam.api.billing.createMeter(root, { tenantId: rootTenantId, key: 'api-calls', name: 'API calls', unit: 'request', }); await iam.api.billing.setPrice(root, { tenantId: rootTenantId, meter: 'api-calls', price: { model: 'graduated', includedQuantity: 10_000, tiers: [ { upTo: 1_000_000, unitAmount: 0.0004 }, { upTo: null, unitAmount: 0.0002 }, ], }, }); // A negotiated price for one organization and its projects: await iam.api.billing.setPrice(root, { tenantId: rootTenantId, meter: 'api-calls', targetTenantId: acmeId, price: { model: 'per-unit', unitAmount: 0.0003 }, }); ``` | Setting | Meaning | | ----------------------- | --------------------------------------------------------------------------------- | | `aggregation: 'sum'` | Quantities add up (default). | | `aggregation: 'unique'` | Distinct people and agents with usage in the month: active users or active seats. | | `pricing: 'rate-card'` | Priced from the rate card (default). | | `pricing: 'reported'` | Every event carries its own cost, as AI inference and chargeback entries do. | Keys belong to the tenant nearest the root, so an organization can never redefine a platform meter. A price applies from `effectiveFrom` (a month) until a later entry, and the entry nearest the billing account wins. Past months can be priced until they are invoiced. ## Recording usage [#recording-usage] Server code records usage without a credential and without an audit event per call: ```ts await iam.billing.record({ tenantId: projectId, meter: 'api-calls', quantity: 1, identityId: session.identity.id, tags: { endpoint: 'search' }, idempotencyKey: requestId, }); ``` Backends that only reach the HTTP API use `billing.record` or `billing.recordMany` with a service account holding `iam:billing:record`. Each event is attributed when it is recorded: to the person, service account or agent, to the teams they belong to directly, and to their department. An agent's usage counts toward its sponsor's teams and department. Metered AI inference arrives by itself on the built-in `inference` meter, and `iam.billing.recordSeats()` records one seat per active person each day for seat pricing. ## Reading spend [#reading-spend] ```ts const byTeam = await iam.api.billing.spend(admin, { tenantId, groupBy: 'team' }); const platformTeam = await iam.api.billing.spend(admin, { tenantId, teamId, groupBy: 'identity' }); const sixMonths = await iam.api.billing.trend(admin, { tenantId, months: 6 }); ``` Group by `meter`, `identity`, `agent`, `team`, `department`, `tenant` (projects), `day`, or a tag. Filters narrow to a meter, a person, a team or department (with those below it), or a project; the current month includes a linear forecast. People in several teams count toward each in equal parts, unless the `billing` option `teamAttribution` says `primary` or `full`. | Who | Reads | Needs | | ---------------- | ------------------------------------------------------ | ----------------------- | | Billing managers | `spend`, `trend`, budgets, credits, statements | `iam:billing:read` | | Team maintainers | `teamSpend` for their team and the teams below it | maintaining the team | | Department heads | `departmentSpend` for their department and those below | heading the department | | Everyone | `mySpend`: their own usage and their agents' | a session of the tenant | In React, `useMySpend` and `useSpendCheck` show people their own spend and warn before a blocked action. ## Budgets [#budgets] ```ts await iam.api.billing.createBudget(admin, { tenantId, name: 'Platform team monthly', subjectType: 'team', subjectId: platformTeamId, amount: 600, thresholds: [50, 80, 100], notify: { emails: ['finance@acme.test'] }, enforce: false, }); ``` A budget covers a tenant subtree, a team with its sub-teams, a department with those below it, or one person, per month, quarter or year. `iam.billing.checkBudgets()` alerts once per threshold and window, and when the projection passes the budget: an audit event `billing:budget-alert` that webhooks can forward, and a `spend-alert` email to the owners, the subject (the person, the team's maintainers, the department head) and any extra addresses. An enforced budget refuses covered usage once it is spent (`SPEND_LIMIT_REACHED`, 402, for `record` with `enforceBudgets`), and `billing.check` tells callers in advance. [Inference budgets](/docs/guides/inference) stay the hard, per-call caps on model tokens and cost; billing budgets watch money across every meter. ## Spikes, showback and exports [#spikes-showback-and-exports] Budgets catch spend that adds up; `billing.anomalies` catches spend that jumps: people, teams and meters whose spend yesterday was at least three times their usual daily amount (a looping pipeline, a leaked key, an agent retrying forever). `iam.billing.detectAnomalies()` runs daily and alerts once per spike (`billing:anomaly`, `spend-anomaly` email). For showback, `shareUnattributed: true` spreads spend nobody in the dimension caused over the teams, departments or people that did, by their share. `billing.exportSpend` and `billing.exportStatement` return CSV for spreadsheets. ## Profiles, credit and statements [#profiles-credit-and-statements] A billing profile names who pays: company, billing emails, tax ID, address, purchase order, cost center and payment terms. A profile on a project makes the project a billing account of its own, which is its parent's decision, so it is created from the organization with `targetTenantId`. Root administrators grant credit to billing accounts. `iam.billing.closePeriod()` issues last month's invoice for every billing account with something to bill: one line per platform meter (with the tiers its quantity used), subscription fees and seats, pending invoice items, coupons, credit applied earliest expiry first, tax, the total and due date, and a breakdown of usage by project, team, department (with cost centers) and person. Invoices carry a content hash and bill whole cents, so their lines add up. ## Invoicing [#invoicing] Invoices follow the lifecycle Stripe uses: * **Drafts.** With `billing.autoFinalize: false` (or `closePeriod({ draft: true })`) monthly invoices stay drafts, recomputed on every run, until a root administrator finalizes them. Their month still takes late usage. * **Invoice items** are one-off charges, or credits with a negative amount, for an account's next invoice (`createInvoiceItem`). When credits exceed the charges the rest becomes account credit. * **Payments** (`recordPayment`) may be partial; an overpayment becomes credit. Payment processors report payments with `iam.billing.recordPayment({ number, amount, idempotencyKey })` from their webhooks. * **Credit notes** (`createCreditNote`) reduce the amount due first; a part already paid becomes credit or is recorded as refunded. Invoices with payments or credit notes are corrected with credit notes, never voided. * **Reminders.** `iam.billing.sendPaymentReminders()` emails billing contacts before and after the due date (`billing.paymentReminderDays`, default 3 days before, on the day, 7 and 14 days after). * **Printable invoices.** `renderInvoice` returns a self-contained HTML page with the issuer from `billing.issuer`, to print or save as PDF. ## Plans, subscriptions and coupons [#plans-subscriptions-and-coupons] ```ts await iam.api.billing.createPlan(root, { tenantId: rootTenantId, key: 'team', name: 'Team', selfServe: true, trialDays: 14, items: [ { id: 'platform', kind: 'fee', name: 'Platform fee', amount: 99 }, { id: 'seats', kind: 'seat', name: 'Seats', unitAmount: 12, includedSeats: 3 }, { id: 'calls', kind: 'usage', meter: 'api-calls', price: { model: 'per-unit', unitAmount: 0.0003 } }, ], }); await iam.api.billing.subscribe(orgAdmin, { tenantId: acmeId, plan: 'team', seats: 8 }); await iam.api.billing.redeemCoupon(orgAdmin, { tenantId: acmeId, code: 'LAUNCH20' }); ``` Fees and seats are billed a month ahead (or in arrears per item), prorated when a subscription starts, changes seats, changes plan or ends mid-month; a plan's usage items replace the rate card for its subscribers. Outside a trial the first month is invoiced at once. Organizations manage self-serve plans themselves (`iam:billing:manage`); root administrators manage every plan, end subscriptions at once and create coupons (`percentOff` or `amountOff`, once, for some months, or forever). | Job | CLI | Schedule | | ------------------------------------ | ------------------- | -------- | | `iam.billing.checkBudgets()` | `billing-alerts` | hourly | | `iam.billing.recordSeats()` | `billing-seats` | daily | | `iam.billing.closePeriod()` | `billing-close` | daily | | `iam.billing.sendPaymentReminders()` | `billing-reminders` | daily | | `iam.billing.detectAnomalies()` | `billing-anomalies` | daily | ## In the console [#in-the-console] * **Organization → Billing**: spend this month by person, team, department, project, meter or day, the six-month trend, budgets, invoices with the amount due, the subscription (seats, cancel, keep), self-serve plans, promotion codes, charges waiting for the next invoice, the meters and prices that apply, the billing profile, and chargeback meters. People without billing permissions see their own spend there. * **Invoice** pages: lines with tier sub-lines, service periods and prorations, coupons, payments, credit notes, breakdowns, the integrity check, and "Print or save as PDF". * **Administration → Billing** (root): accounts and what they owe, invoices (finalize, mark paid, write off, void), payments, credit notes, invoice items, plans, subscriptions, coupons, closing a month (optionally as drafts), contract terms, credit, and platform meters and prices. ## Next steps [#next-steps] - [Billing API](/docs/reference/api/billing): Every method, permission, and error of the billing group. - [Teams and departments](/docs/guides/teams-and-departments): The teams, maintainers and department heads that spend is attributed to. - [Model access and budgets](/docs/guides/inference): Model access, per-call token and cost caps, and the gateway that feeds the inference meter. # Frequently asked questions (/docs/guides/faq) > Short, plain answers to the questions people ask before adopting Better IAM, with links to the details. ## What it is [#what-it-is] #### Is Better IAM a hosted service? No. It is a set of TypeScript packages that run inside your own application process and store their data in your own database. There is no Better IAM server to sign up for, and no identity data leaves your infrastructure unless you configure an integration (webhooks, SCIM, Shared Signals) that sends it somewhere. #### How is it different from an authentication library? Authentication libraries sign people in. Better IAM also decides what they may do once signed in, and it manages that access over time: organizations with their own directories and roles, JSON policies with conditions, just-in-time elevation, access reviews, an audit log that detects tampering, and enterprise federation (SSO, SCIM). If your application only needs sign-in for individual users, a smaller library is enough; see [Why it exists](/docs/guides#why-it-exists). #### Is there an admin console? The repository includes a reference console (`apps/console`, a Next.js application) with an administration panel and a multi-tenant cloud console built on the same public API. You can run it as is, or read it as a large example of building your own management screens with the [browser client](/docs/frameworks/client). #### What license is it under? Every package is MIT licensed and published to npm as `better-iam` and the `@better-iam/*` packages. ## Running it [#running-it] #### Which databases does it support? PostgreSQL, SQLite, and libSQL/Turso, through the bundled adapters. Anything else can be added by implementing the storage adapter contract, which comes with a conformance test suite. See [Storage adapters](/docs/operations/storage) and [Adapters and plugins](/docs/operations/extensions). #### Which runtimes does it support? Does it run on the edge? The server (`betterIam()`) needs Node.js 22.12 or newer: password hashing and the SQLite adapter use native modules. For edge runtimes, `better-iam/next/edge` provides what is safe to run there with Web Crypto alone: the Next.js middleware that redirects signed-out visitors, verification of service assertions, and verification of webhook signatures. The browser client and the React, Vue, and Svelte bindings run in any browser. See [Next.js middleware](/docs/frameworks/nextjs/middleware). #### Do I need to run background jobs? Yes, a few, on a schedule: delivering the email outbox, removing expired sessions and records, and optional governance jobs such as expiry reminders and certification deadlines. Each job is both an instance function and a CLI command, so you can run them from a worker or from cron. See [Scheduled jobs](/docs/operations/jobs). #### Does every permission check hit the database? Yes. Decisions are made from the current state in your database, not from a cache, so a revoked role or session stops working on the very next request. To keep pages fast, check many things at once with `authorizeMany` (up to 50 checks in one call) and list what someone may access with `listAccessible` instead of checking items one by one. See [Reverse queries and batches](/docs/guides/authorization/queries). ## Adopting it [#adopting-it] #### Can I import my existing users? Yes. [`identities.createMany`](/docs/reference/api/identities#createmany) creates many people at once with their names, directory attributes, roles, and groups. It accepts a password only in plain text (it is hashed on the way in), so existing password hashes from another system are not imported: people can set a password through the reset flow, or sign in with a magic link, a passkey, or your enterprise identity provider. Directories that support SCIM can provision people automatically; see [SCIM](/docs/federation/scim). The [migration recipe](/docs/guides/recipes/tenancy-and-limits#move-people-over-from-another-system) walks through a complete move, including two traps with unverified addresses and federated sign-in. #### Can people sign in with Google, GitHub, Microsoft, or our company's identity provider? Yes. OAuth and OpenID Connect sign-in include presets for Google, GitHub, and Microsoft Entra ID, and any standard OIDC provider works. Organizations can also connect their own SAML identity provider. See [OAuth and OIDC sign-in](/docs/federation/oauth-sign-in) and [SAML](/docs/federation/saml). #### Can Better IAM be the identity provider for my other applications? Yes. The OAuth/OIDC provider in `better-iam/oauth` issues tokens to your other applications and to MCP servers, with consent screens, refresh tokens, and token verification helpers for your APIs. See [OAuth/OIDC provider](/docs/federation/oauth-provider). #### Can I trust permission checks made in the browser? No, and the API is designed around that. Browser checks (`Can`, `useAuthorize`, `authorizeMany`) are advisory: they decide what to show. Your server must enforce the decision with `iam.require` immediately before doing the protected work, using resource details loaded from your own storage. #### How do I try policies without writing code? Open the [policy playground](/playground). It runs the same policy engine as the server, in your browser, and shows which statement and condition made each decision. # Feature flags (/docs/guides/feature-flags) > Turn product features on and off per organization without a deploy, from the platform or from each organization, and gate the same features in the UI and in authorization. A new feature rarely reaches every customer on the same day. You want to try it with a design partner first, roll it out to a quarter of your customers, let each organization opt in to a beta, and switch it off everywhere in seconds when something goes wrong, all without a deploy. Feature flags do that, and because they live in Better IAM next to your organizations, a flag can decide what the UI shows and what authorization allows with the same switch. Every flag is a boolean, and flags work at two levels: * **Platform flags** are defined on the root tenant by root administrators (or anyone the root tenant grants `iam:features:manage`). They reach every organization and project. * **Organization flags** are defined by an organization (or a project) for its own subtree, for example to roll a feature out to some of its projects, or to let each project choose. ## How a value is decided [#how-a-value-is-decided] A flag's value for a tenant comes from the first of four rules that applies: 1. **Kill switch.** `killSwitch: true` turns the flag off everywhere. Targets and overrides stay stored and apply again when the switch is lifted. 2. **The closest target or override.** Walking from the tenant up to (but not including) the tenant that defines the flag, the first tenant with a value decides: * A **target** is a value the flag's managers pin for a tenant below them with `features.setTarget`, such as "on for Acme until the end of the trial". It applies to that tenant and everything below it, unless a closer target or override decides, and it can lapse (`expiresAt`). * An **override** is a tenant's own choice with `features.setOverride`, allowed only for flags defined with `tenantOverridable: true`. On the same tenant, the tenant's override beats an unlocked target. Overrides are refused for internal flags and on the tenant that defines the flag (change the flag itself there with `features.update`). Withdrawing a choice with `value: null` is always allowed, even while locked. * A **locked** target (`locked: true`) silences overrides at its tenant and below, so an organization cannot undo it. A closer target from the flag's own managers still wins over it. 3. **Rollout.** `rolloutPercentage` (0 to 100) turns an off-by-default flag on for a stable share of the branches directly below the defining tenant, and off for the rest. For a platform flag those branches are organizations, so an organization's projects always land on the same side as the organization. Raising the percentage only adds tenants; nobody who had the feature loses it. `rolloutBucket(key, tenantId)` from `@better-iam/server` shows where a tenant falls. 4. **Default.** When no rollout is set, `defaultValue`. A flag with a rollout must default to off (`defaultValue: false`), because the rollout is the share that is turned on. **Keys belong to the tenant closest to the root.** A tenant cannot define a key an ancestor already defines, and a platform flag created later takes precedence over an organization flag with the same key (`features.list` shows the organization's flag as `shadowed`). So an organization can never switch a platform-gated feature on for itself. Keys start with a lowercase letter and join lowercase letters and digits with single `-`, `_`, or `.` characters (`new-billing`, `reports.v2`; not `1st`, `a--b`, or `trailing-`), up to 64 characters, with at most 200 flags per tenant. ## Managing flags [#managing-flags] ```ts const root = { token: rootSessionToken }; // A platform flag that organizations may turn on for themselves. await iam.api.features.create(root, { tenantId: rootTenantId, key: 'new-billing', description: 'The redesigned billing pages', tenantOverridable: true, }); // A two-week trial for one organization. await iam.api.features.setTarget(root, { tenantId: rootTenantId, key: 'fast-search', targetTenantId: acmeId, value: true, expiresAt: Date.now() + 14 * 86_400_000, note: 'Design partner trial', }); // A gradual rollout to a quarter of all organizations. await iam.api.features.update(root, { tenantId: rootTenantId, key: 'reports-v2', rolloutPercentage: 25 }); // Acme's administrators opt in to the new billing pages. await iam.api.features.setOverride(acmeAdmin, { tenantId: acmeId, key: 'new-billing', value: true }); // Incident: off everywhere, now. await iam.api.features.update(root, { tenantId: rootTenantId, key: 'fast-search', killSwitch: true }); ``` Each flag is the resource `iam/features/{key}` (`features.list` uses `iam/features`), so a policy can delegate one flag: for example `iam:features:override` on `iam/features/new-*` for a product team. Targets are set and listed in the defining tenant; an override is authorized in the tenant making the choice. Every change is audited (`feature:create`, `feature:update` with the settings before and after, `feature:delete`, `feature:target`, `feature:override`) and reaches webhooks like any audit event. The [API reference](/docs/reference/api/features) describes every method. **Internal flags** (`internal: true`) are for trusted server code and policies only. They are hidden from `features.evaluate` and from the `features.list` of tenants below the defining one (root administrators still see them). Targets can reach them, but tenants cannot override them, so `tenantOverridable` is refused for them. Target notes are for the flag's managers (`features.listTargets`). An organization sees that a target exists, its value, whether it is locked, and when it ends, but never the note. ## Reading flags in your application [#reading-flags-in-your-application] On the server, deployment code reads flags without a credential, internal flags included: ```ts if (await iam.features.isEnabled(tenantId, 'new-billing')) showNewBilling(); const values = await iam.features.values(tenantId); // { 'new-billing': true, ... } const [detail] = await iam.features.evaluate(tenantId, { keys: ['new-billing'] }); // { key, value, reason: 'OVERRIDE', scope: 'platform', definedBy, decidedBy, locked, overridable } ``` From a browser or another service, `features.evaluate` needs only a credential of the tenant: a user session, an API key, a role session, or a session token whose tenant it is (root administrators may evaluate any tenant). It is not audited and leaves out ancestors' internal flags: ```ts const { flags } = await client.features.evaluate({ tenantId }); ``` In React: ```tsx import { useFeatureFlag, useFeatureFlags } from 'better-iam/react'; function Billing({ tenantId }: { tenantId: string }) { const { value } = useFeatureFlag({ tenantId, key: 'new-billing' }); return value ? : ; } function Toolbar({ tenantId }: { tenantId: string }) { const features = useFeatureFlags({ tenantId }); return features.isEnabled('fast-search') ? : null; } ``` The hooks return `false` while loading, when signed out, and for unknown keys. Hiding a button is not enforcement, so gate the server side as well, in code or in a policy. ## Flags in policies [#flags-in-policies] Decisions see the keys of every flag that is on for the decision's tenant, internal flags included, as the list `tenant.features`. A policy can then allow an action only where a feature is on: ```json title="Exports only where the exports feature is on" { "effect": "allow", "actions": ["documents:export"], "resources": ["*"], "conditions": { "ArrayContains": { "tenant.features": "exports" } } } ``` The server reads flags only for decisions whose documents name `tenant.features`, so policies that do not use flags pay nothing. Applications cannot supply the key through `resolveContext` or plugins, because the server removes it. Policy lint treats it as a list, so test it with `ArrayContains`, not string operators. [`policies.test`](/docs/guides/authorization/reviews) fills it with the tenant's current flags when the candidate document names it, and a `tenant.features` value you pass in the test's `context` takes precedence. ## In the console [#in-the-console] * **Administration → Feature flags** (root): platform flags with their settings and kill switch, every target and organization choice, a form to target an organization or project (optionally locked or lapsing), and new flags. * **Organization → Features**: the platform's flags as they apply to the organization, with **Turn on**, **Turn off**, and **Use default** where the platform allows a choice, plus the organization's own flags with per-project targets. ## Next steps [#next-steps] - [Features API](/docs/reference/api/features): Every method, permission, and error of the features group. - [Conditions](/docs/guides/authorization/conditions): The operators and context keys policies can test, including lists like tenant.features. # Introduction (/docs/guides) > Better IAM is an embeddable TypeScript platform for authentication, identity provisioning, and access management that runs inside your application, on your database. Better IAM gives an application the identity layer that is usually bought as a hosted service: multi-tenant organizations, sign-in with every modern method, fine-grained authorization, governance, and enterprise federation. It is a library, not a SaaS. It runs in your process, stores its data in your PostgreSQL, SQLite, or libSQL database, and exposes a typed API to your server and browser code. You create one instance with `betterIam()`, giving it a database, a deployment secret, and the URL it is served from, and import that instance wherever your server code needs identity or authorization: ```ts twoslash title="iam.ts" // @noErrors import { betterIam } from 'better-iam'; import { sqliteAdapter } from 'better-iam/adapter-sqlite'; export const iam = betterIam({ database: sqliteAdapter({ filename: './iam.db' }), secret: process.env.BETTER_IAM_SECRET!, baseURL: 'https://identity.example.com', }); ``` ## Why it exists [#why-it-exists] Most business software eventually needs the same identity features: customers are organizations rather than individuals, each organization wants its own roles and its own single sign-on, security teams ask who can do what and who changed it, and enterprise buyers expect SCIM provisioning, MFA policies, and an audit trail. Teams usually assemble this from several services: a hosted sign-in provider, a separate authorization service, and later a governance or provisioning tool. That works, but it has costs: * **Your identity data lives somewhere else.** People, sessions, and roles sit in a vendor's database, so every permission check is a network call, and keeping that data consistent with your own records is your problem. * **Authorization is disconnected from your data.** Deciding "may Alice approve this invoice?" needs the invoice's owner and amount, which the external service does not have. * **Each service has its own model.** Tenants, roles, and audit logs are defined three times and reconciled by hand. Better IAM takes the other approach: it is a library that runs inside your application and stores everything in your database, in the same transactions as your own writes. One tenant model, one authorization engine, and one tamper-evident audit log cover sign-in, access decisions, governance, and federation, all behind a single typed API. > **When you may not need it.** If your application has individual users and no organizations, roles, or enterprise customers, a simpler authentication library is enough. Better IAM earns its place when access control, multi-tenancy, and auditability are part of the product. ## What it covers [#what-it-covers] - [Tenants and organizations](/docs/guides/concepts/tenants-and-identities): Tenant trees with isolated identity directories, invitations, sign-in aliases, and platform root administration. - [Authentication](/docs/guides/authentication): Passwords, passkeys, magic links, email and SMS codes, TOTP, trusted devices, and per-tenant sign-in policies. - [Authorization](/docs/guides/authorization): Roles, versioned JSON policies with conditions and variables, relationships (ReBAC), boundaries, and reverse queries. - [Privileged access](/docs/guides/privileged-access): Just-in-time elevation with approvals, access packages, temporary bindings, and configuration as code. - [Governance](/docs/guides/governance): Access reviews, certification campaigns, separation of duties, role mining, invariants, and change previews. - [Federation](/docs/federation): OAuth/OIDC sign-in and provider, SAML SSO, SCIM in and out, and Shared Signals (CAEP/RISC). - [Audit and events](/docs/guides/events): A tamper-evident, hash-chained audit log with webhooks, subscribers, metrics, and spans. - [Every framework](/docs/frameworks): A typed client plus Next.js, React, Vue, Nuxt, SvelteKit, React Router, NestJS, Express, Hono, and Fastify integrations. ## How it fits together [#how-it-fits-together] Every operation, whether it comes from a browser, a server action, the CLI, or a SCIM connector, runs through the same pipeline: 1. **Resolve the credential.** A session cookie, bearer token, API key, or assumed role becomes a principal: who is calling, how they signed in, and whether they used MFA. 2. **Authorize.** The principal's roles, policies, and boundaries in that tenant decide whether the action is allowed. 3. **Apply the change in one transaction** in your database, so a failure leaves nothing half done. 4. **Record it.** A hash-chained audit event is appended in the same transaction and then fans out to webhooks, in-process subscribers, and metrics. Because there is only one path, nothing can bypass authorization or the audit log, and an administrator using the console is held to the same rules as your own API calls. ## Where to go next [#where-to-go-next] - [Quickstart](/docs/guides/quickstart): Create an instance, bootstrap a tenant, and make your first authorization check in minutes. - [Installation](/docs/guides/installation): Packages, subpath imports, databases, and runtime requirements. - [Policy playground](/playground): Evaluate policy documents in your browser with the real policy engine. - [API reference](/docs/reference/api): Every server API method with HTTP routes and TypeScript signatures. # Model access and budgets (/docs/guides/inference) > Decide who may call which AI models, cap what people, teams, and agents spend, meter every call, and keep provider keys away from callers with a gateway. Once people and agents call AI models from your product, three questions come up quickly: who may use the expensive models, how do you stop one runaway agent from spending the month's budget overnight, and who holds the provider API keys. Answering them in each application means scattered keys and spend you only see on the invoice. Better IAM answers them in one place: model access is an ordinary authorization decision, budgets cap tokens and cost per tenant, group, person, or agent, every call is metered, and a gateway lets any Better IAM credential call models without ever seeing a provider key. It works for people, service accounts, and [AI agents](/docs/guides/ai-agents), including agents acting on a person's behalf. Turn it on with the `inference` option: ```ts title="iam.ts" export const iam = betterIam({ // ... inference: { usageRetentionDays: 90, // per-call usage records (1 to 3650) allowCustomBaseUrls: false, // only root administrators may point providers at custom URLs }, }); ``` The option adds the resource type `model` and the action `inference:invoke` to the permission catalog, the [`inference` API group](/docs/reference/api/inference), and `iam.inference`, the server-side runtime with the gateway. ## Providers and models [#providers-and-models] A **provider** is an upstream account: `anthropic`, `openai`, or `openai-compatible` (any endpoint that speaks the OpenAI Chat Completions API, such as vLLM or a router). Its API key is sealed with the deployment secret, never returned, and opened only inside the server when the gateway calls the provider; administrators see its last four characters. ```ts const anthropic = await iam.api.inference.createProvider(admin, { tenantId, name: 'Anthropic', kind: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY!, }); ``` Creating and changing providers needs a recent sign-in. A custom `baseUrl` (required for `openai-compatible`) sends the key to that address, so only a root administrator may set one unless the deployment sets `inference.allowCustomBaseUrls`, and it must be https. Replace a key with `updateProvider({ apiKey })`; `iam.rotateSecrets()` re-seals provider keys when the deployment secret changes. A **model** is published under a public name that callers use and policies name (`model/{name}`), served by a provider as its `upstreamModel`: ```ts await iam.api.inference.createModel(admin, { tenantId, name: 'opus', providerId: anthropic.id, upstreamModel: 'claude-opus-5-5', tier: 'frontier', family: 'claude', inputPricePerMTok: 5, outputPricePerMTok: 25, cachedInputPricePerMTok: 0.5, }); ``` Models are inherited: define providers and models once on the root tenant and every organization sees them. An organization may publish its own model under the same name, which takes precedence for it, and `enabled: false` stops every call to a model at once. Providers have bad days. `fallbacks` (up to five model names, in order) keeps calls going when one does: when a model's provider cannot be reached or answers 429, 500, 502, 503, 504, or 529 (overloaded), the gateway tries the next fallback. A fallback is used only if the caller may call it (access and budgets are checked like any call) and it speaks the same wire format. The failed attempt is metered with no tokens against the model asked for, and the answer against the model that served it; responses name that model in `x-better-iam-model` and the one asked for in `x-better-iam-fallback-from`. ```ts await iam.api.inference.updateModel(admin, { tenantId, name: 'opus', fallbacks: ['opus-eu', 'sonnet'], }); ``` ## Who may call which model [#who-may-call-which-model] Model access is a policy decision: `inference:invoke` on `model/{name}`. The model's attributes are available to conditions as `resource.provider` (the provider's display name, such as `Anthropic`), `resource.providerKind` (`anthropic`, `openai`, or `openai-compatible`; use it to match a kind of provider), `resource.upstreamModel`, `resource.tier`, `resource.family`, `resource.contextWindow`, `resource.inputPricePerMTok`, `resource.outputPricePerMTok`, and `resource.enabled`. ```json title="Small models for everyone, the frontier model only with MFA" { "version": 1, "statements": [ { "effect": "allow", "actions": ["inference:invoke"], "resources": ["model/*"], "conditions": { "StringEquals": { "resource.tier": "small" } } }, { "effect": "allow", "actions": ["inference:invoke"], "resources": ["model/opus"], "conditions": { "Bool": { "principal.mfa": true } } } ] } ``` Try the model policy in the playground The playground opens with Alice asking for the frontier model without MFA, which is refused: neither statement applies. Set `principal.mfa` to `true` and the second statement allows it; set `resource.tier` to `small` and the first one does. Principal keys work as usual, so a delegated agent session can use only the models the person may use, within the delegation's scope. Delegated sessions are never MFA-verified (`principal.mfa` is false), so a statement like the second one never admits an agent acting for a person. `inference.listMine` lists the enabled models the caller may use (for a model picker), and `inference.check` answers whether the caller may invoke a model right now, budgets included. ## Budgets [#budgets] A budget caps tokens (`maxTokens`), cost (`maxCostUsd`), the number of calls (`maxRequests`), or any mix of them, per `minute`, `hour`, `day`, or `month` (UTC windows). A `minute` budget on requests or tokens is a true rate limit, for example 60 calls a minute for an agent that might loop. It needs at least one of the three, `alertAtPercent` applies to each limit it sets, and `null` clears a limit on update: | `subjectType` | Covers | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tenant` | Every call made in the tenant. | | `group` | Calls by the group's members. | | `identity` | One identity, always as one pool. For a person, their own calls and those of agents acting for them; for an agent, its own key and every delegated session. | For `tenant` and `group` budgets, `scope: 'shared'` (the default) makes one pool for everyone covered, and `scope: 'each'` gives everyone covered the full amount (for example, one million tokens a day per person). `models` limits a budget to some model name patterns. ```ts await iam.api.inference.setBudget(admin, { tenantId, name: 'Daily per person', subjectType: 'tenant', scope: 'each', period: 'day', maxTokens: 1_000_000, alertAtPercent: 80, }); await iam.api.inference.setBudget(admin, { tenantId, name: 'Triage agent', subjectType: 'identity', subjectId: agent.id, period: 'month', maxCostUsd: 200, }); ``` A call is refused with `BUDGET_EXCEEDED` when a covering budget is spent, or when the call's estimate would not fit in what is left. The gateway estimates from the request (its size in bytes divided by four, plus its output limit: `max_tokens`, `max_completion_tokens`, or `max_output_tokens`); `inference.check` and `iam.inference.authorize` use the `estimatedTokens` you pass (0 when omitted). The first refusal in a window is audited as `inference:budget-exceeded`, and crossing `alertAtPercent` is audited once per window as `inference:budget-alert`, so a webhook can alert on either. Cost is metered in micro-dollars: tokens times the model's price per million tokens. ## The gateway [#the-gateway] The gateway lets any Better IAM credential call models without holding a provider key. It relays each provider's own wire format, streaming included, changing only what it must: * Anthropic Messages, `POST /v1/messages`, for models on an `anthropic` provider, and token counting, `POST /v1/messages/count_tokens` (checked like a call but not metered, since it costs nothing); * OpenAI Chat Completions, `POST /v1/chat/completions`, Responses, `POST /v1/responses`, and Embeddings, `POST /v1/embeddings`, for `openai` and `openai-compatible` providers; * and `GET /v1/models`, which lists the models the caller may use (OpenAI's format, or Anthropic's when the request carries `anthropic-version`). The gateway answers `{basePath}/v1/...`, so mount it on a catch-all route: ```ts title="app/ai/[...path]/route.ts (Next.js)" const gateway = iam.inference.gateway({ basePath: '/ai' }); export const POST = gateway; export const GET = gateway; // Hono: app.all('/ai/*', (c) => gateway(c.req.raw)); ``` Point an SDK at it with a Better IAM credential as the API key: ```ts import Anthropic from '@anthropic-ai/sdk'; const client = new Anthropic({ baseURL: 'https://app.example.com/ai', apiKey: agentKeyOrDelegatedToken, }); await client.messages.create({ model: 'opus', max_tokens: 1024, messages: [{ role: 'user', content: 'Hi' }], }); ``` For each request the gateway authenticates the caller (a bearer token or `x-api-key`), checks `inference:invoke` and the budgets, swaps the public model name for the upstream one, sends the request with the sealed key, streams the answer back, and meters the tokens the provider reports. It swaps the model name back in the answer (except in streams) and asks OpenAI streams to report usage (`stream_options.include_usage`). It also holds each answer to the model's `maxOutputTokens`: a larger `max_tokens`, `max_completion_tokens`, or `max_output_tokens` is lowered, and a request without one gets it (`max_completion_tokens` for `openai` Chat Completions, `max_tokens` for `anthropic` and `openai-compatible`, `max_output_tokens` on `/v1/responses`); embeddings and token counts have none. Everything else passes through. Responses API conversations are stored at the provider under the organization's key, so without a check any caller could continue any other caller's conversation by its id. The gateway records who created each response it relays. `previous_response_id` must name one of the caller's own responses (the same person, through the same agent if an agent is acting); any other id gets 404 `RESPONSE_NOT_FOUND` and never reaches the provider. The `conversation` parameter and `background: true` are refused with 400 `UNSUPPORTED_PARAMETER`, because their state and usage stay out of the gateway's sight. Built-in tools that the provider bills separately from tokens (web search, file search, code interpreter) are not metered. Refusals use each API's own error shape: 401; 403 (`ACCESS_DENIED`, `MODEL_DISABLED`); 404 (`RESPONSE_NOT_FOUND`); 413 (`TOO_LARGE`); 429 (`BUDGET_EXCEEDED`, with `Retry-After` until the window resets); 400 (`WRONG_FORMAT`) when a model is called in the other provider's format, and 400 (`UNSUPPORTED_PARAMETER`); and 502 (`UPSTREAM_UNAVAILABLE`). The provider's own errors pass through with their status. Relayed responses carry `x-better-iam-request-id` and `x-better-iam-model`. ### Keeping your own gateway [#keeping-your-own-gateway] An existing gateway can use Better IAM for the decision and the metering only. It calls `inference.check` with the caller's credential and receives a single-use `ticket` (valid one hour) when the call is allowed, makes the call itself, then redeems the ticket with the token counts through `inference.record`, using its own service account key with `iam:inference:record`. The call is metered as the caller the check saw, even if that caller's session has ended since, so a short session cannot make an allowed call escape its budgets or a delegation's spending cap. In-process code can use `iam.inference.authorize(credential, { model })`, which returns either `{ denied }` or a permit `{ principal, tenantId, check, upstreamModel, provider: { id, kind, baseUrl, apiKey } }` with the opened provider key (never expose it), and then `iam.inference.record(permit, usage)`. When the deployment has [billing](/docs/guides/billing), every metered call, through either path, also lands in the spend ledger on the built-in `inference` meter. ## Usage reports [#usage-reports] `inference.usage` (with `iam:inference:read`) adds up calls between `from` and `to` by `identity`, `agent`, `model`, or `day`, with requests, errors, token counts, and cost. `inference.myUsage` shows a caller their own usage and the standing of every budget that covers them (tokens, cost, and requests used and remaining), and `inference.listBudgets` shows the current window of each shared pool. Usage records are kept for `usageRetentionDays` and budget counters 35 days past their window; `iam.sweepExpired()` deletes them after that. | Action | Allows | | ---------------------- | ---------------------------------------------------------- | | `inference:invoke` | Calling a model (`model/{name}`). | | `iam:inference:manage` | Managing providers, models, and budgets. | | `iam:inference:read` | Listing providers, models, and budgets, and usage reports. | | `iam:inference:record` | Metering calls for others with check tickets (gateways). | ## Next steps [#next-steps] - [AI agents](/docs/guides/ai-agents): Accounts for agents with a sponsor, a ceiling, and delegated sessions. - [Inference API](/docs/reference/api/inference): Providers, models, budgets, checks, and usage. # Installation (/docs/guides/installation) > Install the umbrella package or individual @better-iam packages, choose a database adapter, and configure your runtime. Better IAM is split into separately publishable `@better-iam/*` packages. Most applications install the umbrella `better-iam` package, which depends on all of them and re-exports each one as a subpath. Framework and protocol code loads only when you import its subpath, so unused integrations cost nothing at runtime. ## Requirements [#requirements] | Requirement | Notes | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | Node.js 22.12 or newer | The engines field of every package. Web-standard `Request`/`Response`, Web Crypto, and `AsyncLocalStorage` are used throughout. | | ESM | Packages are ES modules with TypeScript declarations. Use `"type": "module"` or a bundler. | | Native modules | `argon2` (password hashing) and, for SQLite, `better-sqlite3`. Allow their install scripts (pnpm: `allowBuilds` / `onlyBuiltDependencies`). | | A database | PostgreSQL, SQLite, or libSQL/Turso through the bundled adapters, or your own adapter. | | TypeScript (optional) | `moduleResolution` of `bundler`, `node16`, or `nodenext` so subpath exports resolve. | ## Install [#install] **Umbrella package:** npm pnpm yarn bun ```bash npm i better-iam ``` ```bash pnpm add better-iam ``` ```bash yarn add better-iam ``` ```bash bun add better-iam ``` ```ts import { betterIam } from 'better-iam'; import { postgresAdapter } from 'better-iam/adapter-postgres'; import { createIamClient } from 'better-iam/client'; import { createIamNext } from 'better-iam/next'; ``` Framework peers (`next`, `react`, `vue`, `svelte`, `@sveltejs/kit`, `react-router`, `@nestjs/*`) are optional peer dependencies: install the ones your application already uses. **Individual packages:** npm pnpm yarn bun ```bash npm i @better-iam/server @better-iam/adapter-postgres @better-iam/client ``` ```bash pnpm add @better-iam/server @better-iam/adapter-postgres @better-iam/client ``` ```bash yarn add @better-iam/server @better-iam/adapter-postgres @better-iam/client ``` ```bash bun add @better-iam/server @better-iam/adapter-postgres @better-iam/client ``` ```ts import { betterIam } from '@better-iam/server'; import { postgresAdapter } from '@better-iam/adapter-postgres'; import { createIamClient } from '@better-iam/client'; ``` Installing individual packages keeps `node_modules` smaller in services that only need part of the platform, for example a resource server that verifies assertions but never signs anyone in. ## Choose a database [#choose-a-database] Better IAM keeps all of its records in your database through a storage adapter, which you pass as the `database` option. The three bundled adapters share one schema, so you can start on SQLite and move to PostgreSQL later. **PostgreSQL:** ```ts title="better-iam.config.mjs" import { postgresAdapter } from 'better-iam/adapter-postgres'; export default { database: postgresAdapter({ connectionString: process.env.DATABASE_URL }), // ... }; ``` The recommended production store. Uses `pg` and `kysely`. **SQLite:** ```ts title="better-iam.config.mjs" import { sqliteAdapter } from 'better-iam/adapter-sqlite'; export default { database: sqliteAdapter({ filename: './iam.db' }), // ... }; ``` A single file on local disk through `better-sqlite3`. Ideal for development, tests, and single-node deployments. **libSQL / Turso:** ```ts title="better-iam.config.mjs" import { libsqlAdapter } from 'better-iam/adapter-libsql'; export default { database: libsqlAdapter({ url: process.env.LIBSQL_URL, authToken: process.env.LIBSQL_AUTH_TOKEN }), // ... }; ``` SQLite-compatible storage over the network (Turso) or in a local file, through `@libsql/client`. See [Storage adapters](/docs/operations/storage) for durability settings, snapshots, and moving between databases, and [Adapters and plugins](/docs/operations/extensions) to write your own adapter. ## Subpath imports [#subpath-imports] Every subpath of the umbrella package maps to one workspace package, so importing from a subpath loads only that package: | Import | Package | What it provides | | ------------------------------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `better-iam` | `@better-iam/server` and `@better-iam/core` | The common entry points: `betterIam()`, `IamError`, `definePolicy`, `evaluatePolicy`, `verifyAssertion`, `verifyWebhookSignature`, and the main types | | `better-iam/server` | `@better-iam/server` | Everything the server package exports, including the route tables and server types | | `better-iam/core` | `@better-iam/core` | Models, `evaluatePolicy`, `definePolicy`, the storage contract, `IamError` | | `better-iam/auth`, `better-iam/auth/templates` | `@better-iam/auth` | Authentication service internals and `renderDeliveryMessage` | | `better-iam/client`, `/client/session`, `/client/passkeys` | `@better-iam/client` | Typed browser client, session store, WebAuthn helpers | | `better-iam/adapter-postgres`, `/adapter-sqlite`, `/adapter-libsql` | `@better-iam/adapter-*` | Storage adapters | | `better-iam/oauth`, `better-iam/saml`, `better-iam/scim` | `@better-iam/oauth`, `saml`, `scim` | Federation protocols | | `better-iam/react`, `better-iam/vue` | `@better-iam/react`, `vue` | UI bindings | | `better-iam/next`, `/next/edge`, `/next/client` | `@better-iam/next` | Next.js App Router helpers | | `better-iam/svelte`, `/svelte/kit` | `@better-iam/svelte` | Svelte stores and SvelteKit hooks | | `better-iam/react-router` | `@better-iam/react-router` | React Router middleware and guards | | `better-iam/nestjs`, `/nestjs/testing` | `@better-iam/nestjs` | NestJS module, guard, decorators | | `better-iam/middleware`, `/express`, `/hono`, `/fastify` | `@better-iam/middleware` | Node framework middleware | | `better-iam/cli`, `better-iam/projects` | `@better-iam/cli`, `projects` | CLI entry point, reference plugin | The Nuxt module is published separately as `@better-iam/nuxt` because it depends on `@nuxt/kit`. ## All packages [#all-packages] ## Verify the installation [#verify-the-installation] Before you serve traffic, check that the configuration loads and the database is ready: ```bash npx better-iam doctor --config better-iam.config.mjs ``` `doctor` reports schema, bootstrap, secret strength, durability, email transport, and scheduled-job problems, and `--strict` exits non-zero on any warning so you can run it in CI. See the [CLI reference](/docs/reference/cli#doctor). ## Next steps [#next-steps] - [Quickstart](/docs/guides/quickstart): Migrate, bootstrap the root, create an organization, and make your first authorization check. - [Core concepts](/docs/guides/concepts): How tenants, identities, resources, and the request pipeline fit together. # Onboarding (/docs/guides/onboarding) > Checklists for new members and new tenants, defined by the platform and customized by each organization and project. A new member's first day has a script: read the rules, fill in a profile, set up two-step verification, collect a laptop. A new customer organization has one too: add a second owner, verify the email domain, require MFA, pass a business verification. The platform wants the same basics everywhere, each organization adds its own, and a project inside an organization adds a little more. Onboarding flows are those scripts, stored in Better IAM next to your tenants, so the steps follow the tenant tree, completion shows up in reports, and a policy can hold access back until the required steps are done. There are two kinds of flow: * **Member onboarding** (`audience: 'member'`) walks people who join a tenant through their first steps. * **Tenant setup** (`audience: 'tenant'`) is a checklist for the administrators of new organizations or projects below the tenant that defines it. ## Levels [#levels] Flows are defined at any level of the tenant tree and inherited downward: A flow's `appliesTo` decides whom it reaches: `tenant` (the defining tenant's own people), `descendants` (the people of the tenants below it) or `subtree` (both). It defaults to `descendants` at the platform root and `tenant` elsewhere. `tenantTypes` narrows the tenants below to some types, such as `['project']`. A person sees the flows of every level at once, platform first. Each tenant customizes what it inherits with `onboarding.setSettings`: * **Switching flows off.** `disabledFlowIds` turns inherited member flows off for the tenant and everything below it. A flow defined with `locked: true` cannot be switched off. Setup checklists from above always apply. * **The welcome screen.** `welcomeTitle`, `welcomeMessage`, `supportEmail`, and `supportUrl`: for each value, the nearest level that set it wins. The platform sets defaults, an organization overrides the message, and a project overrides only the title. Flows ask only people (or tenants) created after the flow took effect, so publishing a flow never interrupts everyone at once. `includeExisting: true` asks existing ones too. ## Steps [#steps] A flow has 1 to 25 steps. Each has a stable `id`, because progress is kept per step: | Kind | Who | Completes when | | -------------- | ------ | ------------------------------------------------------------------------------------------------------ | | `form` | both | The answers are submitted (text, textarea, email, URL, number, yes/no, select, and date fields). | | `acknowledge` | both | The reader confirms the text. | | `task` | both | Marked done, or approved by an administrator (`verification: 'admin'`). | | `agreement` | member | The person accepts the named terms of use of their own tenant. Skipped where the tenant has none. | | `verify-email` | member | The email address is verified. | | `mfa` | member | An authenticator app or a passkey is enrolled. | | `passkey` | member | A passkey is registered. | | `check` | tenant | The tenant's own state meets it: a verified domain, enough members or owners, an MFA policy, and more. | `optional: true` steps never hold a flow back. Steps that complete on their own follow the live state: removing the last passkey reopens a `passkey` step. ```ts // The platform: every member of every organization and project reads the rules. await iam.api.onboarding.createFlow(rootSession, { tenantId: rootTenantId, name: 'Platform essentials', audience: 'member', locked: true, steps: [ { id: 'conduct', kind: 'acknowledge', title: 'Acceptable use', content: 'Use the service lawfully.' }, { id: 'mfa', kind: 'mfa', title: 'Set up two-step verification' }, ], }); // The platform: every new organization completes a setup checklist the platform reviews. await iam.api.onboarding.createFlow(rootSession, { tenantId: rootTenantId, name: 'Organization setup', audience: 'tenant', tenantTypes: ['organization'], steps: [ { id: 'owners', kind: 'check', title: 'Add a second owner', check: 'owners', minimum: 2 }, { id: 'domain', kind: 'check', title: 'Verify your email domain', check: 'verified-domain' }, { id: 'kyc', kind: 'task', title: 'Business verification', verification: 'admin' }, ], }); // An organization: its engineers collect a laptop and then join a group. await iam.api.onboarding.createFlow(acmeAdmin, { tenantId: acmeId, name: 'Engineering onboarding', audience: 'member', rule: { include: [{ StringEquals: { 'principal.department': 'Engineering' } }] }, completionGroupIds: [engineersGroupId], steps: [{ id: 'laptop', kind: 'task', title: 'Collect your laptop', verification: 'admin' }], }); ``` `rule` targets a member flow with the [automatic assignment](/docs/guides/privileged-access/automatic-assignment) rule language, and `completionGroupIds` adds people to groups when they finish, which needs the same rights as adding them by hand. ### Answers that fill attributes [#answers-that-fill-attributes] A form field with `attribute` fills a declared identity attribute, so the department a new member picks can drive targeting rules, access packages, and policies. Onboarding fills only **empty** attributes, never overwriting a value an administrator or directory sync set, and only for the **defining tenant's own people**: a tenant has no authority over the identities of the tenants below it, so a flow that reaches only descendants cannot map fields at all. Mapping fields needs `iam:identities:update` when the flow is saved. ## Working through onboarding [#working-through-onboarding] People work through their own flows without any permission, from an ordinary session of their tenant: ```ts const mine = await client.onboarding.mine({ tenantId }); // { welcome, flows: [{ name, source, required, steps, done, total, complete }], pending, complete } await client.onboarding.submitStep({ tenantId, flowId, stepId: 'conduct', acknowledged: true }); await client.onboarding.submitStep({ tenantId, flowId, stepId: 'team', answers: { department: 'Engineering' } }); ``` Tenant setup is completed by the tenant's administrators: `onboarding.setup` lists the checklists, and `onboarding.submitSetupStep` (`iam:onboarding:manage`) completes forms, acknowledgements, and tasks. Impersonating administrators can look but never complete steps for someone. The tenant that defines a flow follows progress with `onboarding.progress`: its own people with their answers, and per-tenant counts for the tenants below (never names). Setup reports list every tenant being set up with its answers. `onboarding.verifyStep` approves or sends back a verified task, and `onboarding.resetProgress` starts a flow or one step over. ## Onboarding in policies [#onboarding-in-policies] Decisions for a person in their own tenant can see `principal.onboarding` (the names of the member flows they have completed) and `principal.pendingOnboarding` (how many required flows are still open, a number). A deny statement holds access back until onboarding is done: ```json title="No documents until onboarding is done" { "effect": "deny", "actions": ["documents:*"], "resources": ["*"], "conditions": { "NumericGreaterThan": { "principal.pendingOnboarding": 0 } } } ``` `{ "ArrayContains": { "principal.onboarding": "Security basics" } }` grants something only to people who finished one flow. The server reads these keys only for decisions whose documents name them, so policies that do not use them pay nothing. Finishing onboarding never depends on them: accepting terms, verifying an email address, and enrolling MFA use their own APIs, so nobody is locked out of the steps that would let them in. ## In the console [#in-the-console] * **Administration → Onboarding** (root): platform flows, the platform welcome screen, and progress reports, including every organization's setup answers with **Approve** and **Send back** for verified tasks. * **Organization → Onboarding**, in an organization or a project: the levels, the welcome screen and switched-off inherited flows, the flows inherited from above, the tenant's own flows, and a builder with templates. * **Get started**: the signed-in person's checklist. A banner links to it while required steps are open. * **Organization → Setup checklist**, and a **Finish setting up** card on the overview. ## Next steps [#next-steps] - [Onboarding API](/docs/reference/api/onboarding): Every method, permission, and error of the onboarding group. - [Terms of use](/docs/guides/governance/agreements): Versioned agreements that onboarding steps and policies can require. - [Conditions](/docs/guides/authorization/conditions): The operators and context keys policies can test, including principal.pendingOnboarding. # Quickstart (/docs/guides/quickstart) > Install Better IAM, migrate a database, bootstrap the root, create an organization, and make your first authorization check. This walkthrough builds a working identity layer on SQLite in a few minutes: a configured instance, a migrated database, a root administrator signed in with MFA, an organization with an owner, a custom role, and an enforced authorization check. Every step uses the same APIs you will use in production. > **Requirements.** Node.js 22.12 or newer. Better IAM is ESM-only (set `"type": "module"` in your `package.json`) and ships TypeScript declarations. The SQLite adapter uses the native `better-sqlite3` driver and password hashing uses `argon2`, so your package manager must be allowed to run their install scripts. You also need an authenticator app (any TOTP app) for the root administrator's second factor. ### Install [#install] npm pnpm yarn bun ```bash npm i better-iam ``` ```bash pnpm add better-iam ``` ```bash yarn add better-iam ``` ```bash bun add better-iam ``` The umbrella package installs every `@better-iam/*` package except the Nuxt module, and exposes them as subpaths such as `better-iam/adapter-sqlite`, `better-iam/client`, and `better-iam/next`. Protocol packages (OAuth, SAML, SCIM) load only when you import their subpaths. See [Installation](/docs/guides/installation) to install individual packages instead. ### Create a secret [#create-a-secret] Better IAM derives its signing and encryption keys from one deployment secret of at least 32 characters. Keep it stable across restarts and processes, and store it like a database password. ```bash node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))" ``` ```ini title=".env" BETTER_IAM_SECRET=paste-the-generated-value-here BETTER_IAM_BASE_URL=http://localhost:3000 ``` Better IAM does not load `.env` files itself. Make these variables available to every process that loads the configuration (your server, your scripts, and the CLI), for example with `export` in your shell or your framework's `.env` support. Rotating the secret later is supported with `previousSecrets`; see [Secrets](/docs/operations/deployment/secrets). ### Configure the instance [#configure-the-instance] Put the options in a module whose default export is the configuration. Your application and the `better-iam` CLI both load it, so migrations and jobs always see the same settings. ```ts twoslash title="better-iam.config.mjs" // @noErrors import { sqliteAdapter } from 'better-iam/adapter-sqlite'; export default { database: sqliteAdapter({ filename: './iam.db' }), secret: process.env.BETTER_IAM_SECRET, baseURL: process.env.BETTER_IAM_BASE_URL, authentication: { // Development only: print each invitation, link, and code, including its token. In production, // send the message with your mail provider, deduplicate retries by message.id, and never log payloads. sendEmail: async (message) => { console.log(message.template, message.to, message.payload); }, }, permissions: { resourceTypes: { document: { actions: ['documents:read', 'documents:write'], attributes: { ownerId: 'string' }, }, }, }, // Authorization calls this to learn which tenant owns one of your documents. Replace findDocument // with a lookup in your own database; never trust a tenant ID taken from the request. resolveResource: async ({ type, id }) => { const document = await findDocument(id); return { type, id, tenantId: document.tenantId, attributes: { ownerId: document.ownerId } }; }, }; ``` * `database` chooses the storage adapter, here a SQLite file. * `secret` and `baseURL` come from the environment you set in the previous step. * `authentication.sendEmail` delivers invitations, verification links, and codes. Creating an organization fails with `DELIVERY_REQUIRED` without it. * `permissions.resourceTypes` declares the kinds of things your product protects and the actions that apply to them, so policies and roles that use these names are validated when they are saved. * `resolveResource` loads the owner and attributes of your own records at decision time. `document` is an application-owned type (your database holds documents, not Better IAM), so checks on it fail with `RESOURCE_RESOLVER_REQUIRED` without a resolver. See [resources and catalog](/docs/guides/concepts/resources-and-catalog#application-owned-and-managed-resources). Then create the instance your code imports: ```ts twoslash title="iam.ts" // @noErrors import { betterIam } from 'better-iam'; import config from './better-iam.config.mjs'; export const iam = betterIam(config); ``` ### Migrate and bootstrap [#migrate-and-bootstrap] `migrate` creates the schema. `bootstrap` creates the platform root tenant and its first administrator exactly once. It reads the administrator's credentials from the environment, because secrets are never accepted as command-line arguments. ```bash npx better-iam migrate --config better-iam.config.mjs export BETTER_IAM_ROOT_EMAIL=root@example.com export BETTER_IAM_ROOT_NAME="Platform administrator" export BETTER_IAM_ROOT_PASSWORD='a long root password' # at least 12 characters npx better-iam bootstrap --config better-iam.config.mjs ``` `bootstrap` prints what it created as JSON: ```json { "tenant": { "id": "3f2c9a4e-…", "name": "Platform", "type": "root", "status": "active", … }, "identity": { "id": "usr_…", "email": "root@example.com", "rootAdmin": true, … }, "mfaEnrollmentRequired": true } ``` Copy `tenant.id`: it is the root tenant's ID, which the next steps call `rootTenantId`. Every sign-in names a tenant, because the same email address can belong to separate identities in different tenants. ### Serve the HTTP API [#serve-the-http-api] The instance exposes a Web-standard `handler` and a Node `nodeHandler`. Mount either one; the typed browser client and every framework integration talk to it under `/api/iam`. ```ts title="server.ts" import { createServer } from 'node:http'; import { iam } from './iam'; createServer(iam.nodeHandler).listen(3000); ``` Emails are queued in the database with the change that caused them, and reach `sendEmail` only when `iam.auth.dispatchOutbox()` runs. A deployment runs it on a schedule (see [scheduled jobs](/docs/operations/jobs)); the setup script below calls it directly. Using a framework? Skip this step and follow the [Next.js](/docs/frameworks/nextjs), [Nuxt](/docs/frameworks/nuxt), [SvelteKit](/docs/frameworks/sveltekit), [NestJS](/docs/frameworks/nestjs), or [Express, Hono, and Fastify](/docs/frameworks/node) guide. ### Sign in as the root administrator [#sign-in-as-the-root-administrator] Root authority always requires an MFA-verified session, so the root administrator can do nothing until it enrolls an authenticator. Its first sign-in therefore returns a **challenge** with `enrollmentRequired: true` instead of a session. The rest of this walkthrough is one setup script that calls `iam.api` directly, the way your backend and admin tooling do; it starts by completing that enrollment. ```ts title="setup.ts" import { createInterface } from 'node:readline/promises'; import { iam } from './iam'; const terminal = createInterface({ input: process.stdin, output: process.stdout }); const rootTenantId = '3f2c9a4e-…'; // tenant.id from the bootstrap output // First factor: the password. The result is an MFA challenge, not a session. const signIn = await iam.api.auth.signIn({ tenantId: rootTenantId, email: process.env.BETTER_IAM_ROOT_EMAIL!, password: process.env.BETTER_IAM_ROOT_PASSWORD!, }); if (!('mfaRequired' in signIn)) throw new Error('Expected an MFA challenge'); const challenge = { tenantId: rootTenantId, challenge: signIn.challenge }; // Second factor: add the secret to your authenticator app, then confirm the code it shows. const { secret } = await iam.api.auth.beginMfa(challenge); console.log('Authenticator key:', secret); const code = await terminal.question('Six-digit code: '); const { token: rootSessionToken, recoveryCodes } = await iam.api.auth.confirmMfa({ credential: challenge, code, }); console.log('Recovery codes (store them safely):', recoveryCodes); ``` * `auth.signIn` checks the password and returns either a session or, as here, an MFA challenge that is valid for five minutes. Enter the code before it expires. * `auth.beginMfa` creates the authenticator secret. It also returns `uri`, an `otpauth://` link you can show as a QR code instead. * `auth.confirmMfa` checks the first code, enables the factor, returns ten single-use recovery codes, and issues an MFA-verified session. Its `token` is the root's bearer token for the next steps. If the root administrator ever loses both the authenticator and the recovery codes, `npx better-iam recover-root` reads the same environment variables and creates a replacement root administrator. Give it an email address the root tenant does not use yet. ### Create an organization [#create-an-organization] Tenants form a tree under the root, and each customer organization is a tenant with its own people and roles. The root creates the organization and invites its owner by email. `tenants.create` needs recent authentication (a session established within the last five minutes), which the session you just received has. ```ts title="setup.ts (continued)" // A credential says who is calling: { token }, or { headers } from an incoming request. const root = { token: rootSessionToken }; const { tenant } = await iam.api.tenants.create(root, { parentId: rootTenantId, type: 'organization', name: 'Acme', slug: 'acme', // a sign-in alias: tenants.lookup({ slug: 'acme' }) finds the tenant ownerEmail: 'owner@acme.test', }); // Hand the queued owner-invitation email to sendEmail, which prints it with its token. await iam.auth.dispatchOutbox(); const invitationToken = await terminal.question('Token from the owner-invitation: '); // The owner redeems the invitation, normally on your invitation page. Acceptance creates their // identity in Acme, activates the tenant, and signs them in. const accepted = await iam.api.tenants.acceptInvitation({ tenantId: tenant.id, token: invitationToken, name: 'Olivia Owner', password: 'another long password', }); if ('mfaRequired' in accepted) throw new Error('Acme requires MFA: complete it as the root did'); const owner = { token: accepted.token }; ``` The new tenant starts `pending` and becomes `active` when the owner accepts. `tenants.acceptInvitation` is public (it needs no credential) because the owner has no account yet. It returns the owner's identity with a session, or with an MFA challenge when the organization requires a second factor. ### Grant access and check it [#grant-access-and-check-it] The owner shapes their organization: a custom role built from catalog permissions, and an invitation that binds it to a new member. ```ts title="setup.ts (continued)" const editor = await iam.api.roles.create(owner, { tenantId: tenant.id, name: 'Editor', permissions: ['documents:read', 'documents:write'], }); await iam.api.identities.invite(owner, { tenantId: tenant.id, email: 'alice@acme.test', roleIds: [editor.id], }); await iam.auth.dispatchOutbox(); // prints Alice's member-invitation terminal.close(); ``` Run the script once, in the shell where you exported the variables, with a TypeScript runner such as `npx tsx setup.ts`. It expects a freshly bootstrapped database: on a second run the root already has an authenticator (answer the challenge with `auth.verifyMfa` instead of enrolling again), and the `acme` alias is taken (`SLUG_TAKEN`). Alice redeems her `member-invitation` token with the public `identities.acceptInvitation`, typically from your invitation page through the browser client. That creates her identity, binds the Editor role, and signs her in; through the HTTP handler her session is kept in a cookie that her later requests carry. Your application then enforces decisions in its own routes with `iam.require`, which throws when the caller may not act, or `iam.authorize`, which returns the decision for you to inspect: ```ts title="routes/documents.ts" import { iam } from '../iam'; export async function updateDocument(request: Request, document: { id: string; tenantId: string }) { await iam.require({ headers: request.headers, // the session cookie or bearer token of the caller tenantId: document.tenantId, action: 'documents:write', resource: { type: 'document', id: document.id }, }); // ...only reached when the caller may write this document } ``` A denied check throws an `IamError` with code `ACCESS_DENIED` (403), and a missing or lapsed credential fails with `UNAUTHENTICATED` (401); the HTTP handler turns both into JSON error responses. See [Error codes](/docs/reference/errors). ## What you built [#what-you-built] ## Next steps [#next-steps] - [Core concepts](/docs/guides/concepts): How tenants, identities, resources, and the request pipeline fit together. - [Authentication](/docs/guides/authentication): Passkeys, magic links, MFA, sessions, and per-tenant sign-in policies. - [Authorization](/docs/guides/authorization): Roles, policies with conditions, relationships, and reverse queries. - [Try policies live](/playground): Evaluate policy documents in the browser with the real engine. # Teams and departments (/docs/guides/teams-and-departments) > Nested teams whose maintainers manage membership and take join requests, and the department tree with heads that policies and approvals follow. A growing organization stops thinking in individual permissions. People work in teams (Platform, Site Reliability, the Payments squad) and sit in a reporting structure (Engineering, then Platform under it). Access should follow the team, the person who runs a team should be able to add a newcomer without filing a ticket with IT, and a policy should be able to say "anyone in Engineering". Groups alone cannot do that: they are flat, and only administrators with authority over their bindings change them. Better IAM adds two structures inside a tenant: * **Teams**: working units that nest, have **maintainers** who manage their membership, can take **join requests**, and give their members access through roles bound to the team. * **Departments**: the reporting structure. Each person belongs to one department, a department can name a **head**, and heads can become everyone's managers so manager approvals follow the org chart. ## How a team grants access [#how-a-team-grants-access] Every team owns a **backing group** (`team.groupId`, named `team:{slug}`). You give a team access by binding roles to that group, exactly as you would for any group: ```ts const platform = await iam.api.teams.create(admin, { tenantId, name: 'Platform', joinPolicy: 'request', maintainerIds: [bob.id], }); const sre = await iam.api.teams.create(admin, { tenantId, name: 'Site Reliability', slug: 'sre', parentId: platform.id, }); await iam.api.bindings.create(admin, { tenantId, roleId: deployer.id, subjectType: 'group', subjectId: platform.groupId, }); ``` The teams module keeps the backing group in step with the team: it holds the team's live members **and the members of every team below it**. Alice, added to Site Reliability, therefore holds Deployer through Platform. Because access flows through an ordinary group, everything that reads groups reads teams: separation of duties, invariants, access reviews, certifications, role mining, relationships, and `principal.groups`. Only the teams API changes who is in a backing group. The groups API refuses with `TEAM_MANAGED`, access packages, invitations, and onboarding flows cannot name a backing group, and configuration as code keeps team groups out of its `groups` and `bindings` kinds. ## Teams and departments as code [#teams-and-departments-as-code] [Configuration as code](/docs/reference/api/config) has a `teams` kind (matched by slug, with parent, department, join settings, maintainers and members by email, and the roles the team holds by name) and a `departments` kind (matched by name, with code, parent, head, cost center, and people by email). Listing a team's `maintainers` or `members` makes its permanent direct members match exactly; temporary memberships and join requests are left alone. Apply creates parents before children and, with `prune`, deletes children first. ```json { "version": 1, "departments": [{ "name": "Engineering", "code": "ENG", "head": "alice@acme.test" }], "teams": [ { "name": "Platform", "slug": "platform", "department": "Engineering", "maintainers": ["bob@acme.test"], "members": ["alice@acme.test"], "roles": ["Deployer"] } ] } ``` ## Maintainers [#maintainers] A team's members are `member`s or `maintainer`s. Maintainers of a team, or of any team above it, manage membership from their own session without an administrator permission: they add, update, and remove members (`teams.candidates` lists people they can add), and decide join requests. Their calls go through the same transaction as an administrator's, so separation-of-duties rules and enforced invariants still apply, and the audit trail records `via: team-maintainer`. ```ts // Bob maintains Platform, so he also manages Site Reliability below it. await iam.api.teams.addMember(bobSession, { tenantId, teamId: sre.id, identityId: carol.id }); ``` Administrators hold `iam:teams:update` and, as with `groups.addMember`, authority over what the team (and the teams above it) hold. Maintainers skip that authority check: making someone a maintainer delegates the team's access to them. Set `memberManagement: 'admins'` on a team whose membership only administrators should change, and remember when you bind a role to a team that its maintainers can hand that role to anyone they add. Members see their own team (`teams.get`, `teams.listMembers`) without `iam:teams:read`. ## Team sync from your directory [#team-sync-from-your-directory] Most organizations already keep team rosters in their identity provider. Give a team `syncGroupIds` (up to ten ordinary groups, such as the groups Okta or Microsoft Entra push over [SCIM](/docs/reference/api/groups)) and its membership follows them: everyone in a source group is a member marked `source: 'sync'`, leaving the group removes them, and a temporary group membership makes a temporary team membership. Each SCIM push updates the team in the same transaction. People added by hand, and maintainers, stay as they are; synced members are changed through the source group. ## Join requests [#join-requests] A team with `joinPolicy: 'request'` takes join requests. `teams.listMine` shows a person their teams, their requests, and the teams they can ask to join; `teams.requestToJoin` sends the request, and the team's maintainers get a `team-join-request` email. A maintainer or administrator answers with `teams.approveRequest` (optionally for a limited time) or `teams.denyRequest`, and the requester gets a `team-join-decided` email. Requests lapse after fourteen days; nobody decides their own. ## Departments [#departments] ```ts const engineering = await iam.api.departments.create(admin, { tenantId, name: 'Engineering', code: 'ENG', headId: alice.id, costCenter: 'CC-100', }); const platformDept = await iam.api.departments.create(admin, { tenantId, name: 'Platform', parentId: engineering.id, headId: bob.id, }); await iam.api.departments.assign(admin, { tenantId, departmentId: platformDept.id, identityIds: [carol.id, erin.id], }); ``` A person belongs to one department at a time, so assigning moves them. Teams can be filed under a department (`departmentId`). Two operations connect the org chart to the rest of the system: * **`importFromAttribute`** places everyone whose string identity attribute (for example `department`, filled by SCIM provisioning) names a department, by name or code, and can create the departments that are missing. Run it with `dryRun: true` first. * **`syncManagers`** makes each person's department head their manager, and a head's the nearest head above, so approvals routed to managers (manager approval on eligible bindings and access packages, manager-reviewed certifications) follow the org chart. ## In policies [#in-policies] | Key | Type | Value | | ------------------------ | ---------- | ------------------------------------------------------------------- | | `principal.teams` | list | IDs of the person's teams and of every team above them | | `principal.departments` | list | The person's department ID and the IDs of every department above it | | `principal.departmentId` | identifier | The person's own department; absent without one | ```json { "version": 1, "statements": [ { "effect": "allow", "actions": ["documents:read"], "resources": ["document/*"], "conditions": { "ArrayContains": { "principal.departments": [""] } } }, { "effect": "allow", "actions": ["documents:write"], "resources": ["document/*"], "conditions": { "StringEquals": { "principal.departmentId": "${resource.departmentId}" } } } ] } ``` The keys are loaded only when a document names them, and describe people in their own organization: an assumed role sees empty lists. ## Leaving [#leaving] Offboarding removes a person from every team (`teamsLeft`) and hands the departments they head to the successor (`departmentsReassigned`). Deleting an identity ends its memberships and clears the departments it headed. ## In the console [#in-the-console] **Directory → Teams** lists your teams and the teams you can ask to join, shows every team as a tree, and opens a page per team with members, join requests, the roles the team holds (its own and inherited), and settings. Maintainers see the same page with the membership tools only. **Directory → Departments** shows the org chart, imports departments from attributes, and syncs managers. Member pages show a person's teams and department. ## Next steps [#next-steps] - [Teams API](/docs/reference/api/teams): Every method, permission, and error of the teams group. - [Departments API](/docs/reference/api/departments): The org chart, imports, and manager sync. - [Conditions](/docs/guides/authorization/conditions): The operators and context keys policies can test, including principal.teams and principal.departments. # Advanced (/docs/frameworks/nextjs/advanced) > Step-up, API keys in route handlers, assertions and webhooks for other services, client refresh, the Pages Router, and background delivery in Next.js. This page covers the parts of `@better-iam/next` that most applications add after the basics: * [Step-up](#step-up): demand a recent or multi-factor sign-in for sensitive pages. * [Service credentials](#service-credentials): accept API keys and assumed roles in route handlers. * [Keeping server components in sync](#keeping-server-components-in-sync) with sign-ins in the browser. * [Downstream services and webhooks](#downstream-services-and-webhooks): talk to other services both ways. * [Background work](#background-work): send email and webhooks on serverless hosts. * The [Pages Router](#pages-router). ## Step-up [#step-up] A valid session only proves that someone signed in, possibly days ago and possibly on a shared computer. Pages that change security settings, show secrets, or lead to irreversible operations should also know that the person is present now and passed a second factor. Step-up lets a guard demand that, and send the person to confirm who they are instead of refusing outright. `stepUp: { mfa?: true | 'fresh', maxAgeMs?: number }` works on `page`, `route`, `apiRoute`, `action`, `pages.withSession`, `pages.api`, and `requireSession`. Guards check it after authenticating and before authorizing, with `checkStepUp(session, requirement, now)`. You can also call that function yourself: it returns `null` when the session qualifies, or `{ code, reason, message, status: 403 }`. | Requirement | Satisfied by | Refused with | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | `mfa: true` | A session that completed MFA, including one a remembered device let through, and impersonated or assumed-role sessions derived from one | `MFA_REQUIRED` (`reason=mfa`) | | `mfa: 'fresh'` | A user session that verified a factor itself | `MFA_REQUIRED`, or `IMPERSONATION_RESTRICTED` for impersonation | | `maxAgeMs` | `authenticatedAt` within the window (and not in the future) | `RECENT_AUTH_REQUIRED` (`reason=recent`), or `IMPERSONATION_RESTRICTED` | `mfa: 'fresh'` refuses sessions a remembered device satisfied, impersonated sessions, assumed roles, and API keys. Sessions from `links.switch` do not carry the remembered-device marker, so `'fresh'` cannot tell them apart. Impersonated sessions never satisfy a recency requirement, as on the server. An invalid requirement (an `mfa` other than `true`, `false`, or `'fresh'`, or a `maxAgeMs` that is not a positive finite number) throws a `TypeError` when you create the guard (for `requireSession` and `checkStepUp`, when they run). **Protected page:** ```tsx title="app/[org]/security/page.tsx" import { iamNext } from '@/lib/iam-next'; // A session older than five minutes, or without MFA, is sent to /reauth?next=...&reason=... first. export default iamNext.page( async (_props: { params: Promise<{ org: string }> }, { session }) => (

Security settings

You signed in at {new Date(session.session.authenticatedAt).toISOString()}.

), { stepUp: { maxAgeMs: 5 * 60_000, mfa: true } }, ); ``` **Step-up page:** ```tsx title="app/reauth/page.tsx" import { ReauthenticateForm } from '@better-iam/next/client'; import { iamNext } from '@/lib/iam-next'; import { reauthenticate } from '../auth-actions'; const reasons: Record = { recent: 'This page needs a recent sign-in. Confirm your password to continue.', mfa: 'This page needs a second factor.', impersonation: 'This page is unavailable while viewing as another member.', }; export default async function Reauthenticate(props: { searchParams: Promise<{ next?: string; reason?: string }>; }) { const { next, reason } = await props.searchParams; const session = await iamNext.requireSession({ returnTo: '/reauth' }); return (

Confirm it’s you

{reasons[reason ?? 'recent'] ?? reasons.recent}

Signed in as {session.identity.email}.

); } ``` **lib/iam-next.ts:** ```ts title="lib/iam-next.ts" export const iamNext = createIamNext(iam, { loginPath: '/login', stepUpPath: '/reauth' }); ``` Pages redirect to `stepUpPath` (or `stepUp.redirectTo`) with `?next=` and `?reason=` (`mfa`, `recent`, or `impersonation`). A `ReauthenticateForm` posting to `authActions().reauthenticate` there asks for the password and then the second factor when the account has one. That issues a new session with a fresh `authenticatedAt`, ends the session it replaced, and returns to `next`. The server has no in-place upgrade, so a new session is the only way to prove recency. The new cookie stays a browser-session cookie unless the form opts in with `keepSignedIn`. Route handlers, `apiRoute()`, and `pages.api()` answer 403 with the code, and `action()` returns it as an `ActionResult`. ### Without a step-up page [#without-a-step-up-page] Without `stepUpPath`, page guards throw an `IamError` with the failure's `code`, `status`, and `reason`, whose `digest` is `BETTER_IAM_STEP_UP::`. Production builds pass only the digest to `error.tsx`, so match on that prefix there: ```tsx title="app/error.tsx" 'use client'; export default function ErrorPage({ error }: { error: Error & { digest?: string } }) { if (error.digest?.startsWith('BETTER_IAM_STEP_UP:')) return Confirm it’s you to continue; return

Something went wrong.

; } ``` `maxAgeMs` is independent of the server's `authentication.recentAuthenticationMs` (five minutes by default), which guards the server's own sensitive operations. Keep the two aligned when a page leads to such an operation. The `now` option of `createIamNext` replaces the clock for tests. See [Sessions](/docs/guides/authentication/sessions) for recent authentication on the server. ## Service credentials [#service-credentials] Scripts, CI jobs, and other services call your routes with API keys, and administrators may act through an assumed role. `route()` accepts only user sessions, so those callers need `apiRoute(handler, spec)`, the variant of `route()` for machine callers. It authenticates with `iam.authenticate`, so API keys and assumed-role sessions work as well as browser sessions (cookie or bearer). ```ts title="app/api/whoami/route.ts" import { iamNext } from '@/lib/iam-next'; export const runtime = 'nodejs'; // Accepts browser sessions, API keys, and assumed roles; the handler sees a sanitized principal. export const GET = iamNext.apiRoute( async (_request, { principal }) => ({ id: principal.identity.id, name: principal.identity.name, tenantId: principal.session.tenantId, kind: principal.session.kind, mfa: principal.session.mfa, }), { authorize: { action: 'reports:read' } }, ); ``` The handler receives `{ principal, params }`. `principal` (the principal) is an `IamPrincipal` copied field by field from the stored records, never the records themselves, so token hashes and session policies cannot leak into a response: | Part | Fields | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `principal.identity` | `id`, `tenantId`, `name`, `email?`, `kind?` (`user` or `service`), `status?` | | `principal.session` | `id`, `tenantId` (the tenant the credential acts in), `kind` (`user`, `role`, or `api-key`), `mfa`, `method?`, `authenticatedAt`, `expiresAt`, `roleId?`, `impersonatorId?`, `trustedDeviceId?` | * **Tenant.** `authorize` defaults to the tenant the credential acts in: the role's tenant for an assumed role, which can differ from the identity's tenant. Its callbacks receive `{ principal, params, request }`. * **Step-up.** API keys never have MFA, and their `authenticatedAt` is their creation time, so `stepUp` refuses them for `mfa` and limits their age for `maxAgeMs`. Assumed roles inherit both from the session that assumed them and never satisfy `mfa: 'fresh'`. * **Everything else** (the origin check for cookie mutations, JSON results, the error envelope) works as in [`route()`](/docs/frameworks/nextjs/guards#route-handlers). `apiRoute()` needs an instance with `authenticate()`, which `betterIam()` provides. API keys come from [`credentials.create`](/docs/reference/api/credentials#create), usually for a [service account](/docs/reference/api/service-accounts), and assumed-role sessions from [`roles.assume`](/docs/reference/api/roles#assume). ## Keeping server components in sync [#keeping-server-components-in-sync] A sign-in or sign-out can happen in the browser: through the typed client, a passkey ceremony, or a session that expired while the tab was open. It changes the cookie, but App Router server components that already rendered keep showing the old identity until the next navigation. `IamNextProvider` from `@better-iam/next/client` wraps `IamProvider` and calls `router.refresh()` whenever the signed-in identity changes, so layouts and pages re-render with the new cookie. ```tsx title="app/providers.tsx" 'use client'; import type { ReactNode } from 'react'; import { createIamClient } from '@better-iam/client'; import { IamNextProvider, useSignOut } from '@better-iam/next/client'; import type { BetterIam } from '@better-iam/server'; const client = createIamClient({ baseURL: typeof window === 'undefined' ? 'http://localhost' : window.location.origin, }); type Session = Awaited>; export function Providers(props: { initialSession: Session | null; children: ReactNode }) { return ( {props.children} ); } /** Signing out here refreshes the server components through IamNextProvider. */ export function SignOutButton() { const signOut = useSignOut({ redirectTo: '/login' }); return ( ); } ``` * **Seed it from the server.** Pass `initialSession={await iamNext.sessionForClient()}` from the root layout, so the first render needs no fetch and doesn't trigger a refresh. The refresh fires only when the identity changes after that: sign-in, sign-out, an account switch, or an expired session noticed on focus. * **`useSignOut({ redirectTo })`** signs out through the session store, navigates with `router.replace` when `redirectTo` is given, and refreshes, in one call. * **`useRouterSync()`** is the hook inside `IamNextProvider`, for a provider tree you assemble yourself. * **Re-exports.** The entry re-exports `useSession`, `useAuthorize`, `useAccessible`, `useIamClient`, and `Can` from [`@better-iam/react`](/docs/frameworks/react), along with the auth forms. Server actions need none of this. Next re-renders the page after an action, and `getSession()` reads the cookies the action set. ## Downstream services and webhooks [#downstream-services-and-webhooks] Two helpers connect your app with other services in each direction: assertions carry the signed-in person's identity to a service you call, and webhooks tell your app about changes made somewhere else. ### Assertions for other services [#assertions-for-other-services] Your Next.js app often calls other services (billing, reports, search) on the person's behalf. Forwarding the session token would let every service act as that person everywhere, and each would need the IAM database to check it. Instead, the app mints a short-lived signed assertion for one audience, and the service verifies it offline with a key. `iamNext.assertion({ tenantId, audience, ttlSeconds?, claims? })` mints one about the current session from a server component or route handler, and returns `{ token, expiresAt, claims }`. The caller needs `iam:assertions:create` on `iam/{audience}`, so administrators decide which services each role may call. ```ts title="app/api/reports/route.ts" export const runtime = 'nodejs'; export const GET = iamNext.route(async (_request, { session }) => { const { token } = await iamNext.assertion({ tenantId: session.session.tenantId, audience: 'reports' }); const response = await fetch('https://reports.internal/summary', { headers: { authorization: `Bearer ${token}` }, }); return response.json(); }); ``` The receiving service needs only the derived key, `iam.assertionKey()`, kept in its environment. It verifies tokens offline with `withAssertion` or `verifyAssertionToken` from `@better-iam/next/edge`. Verification runs on Web Crypto, including in edge route handlers and middleware, and follows the server's `verifyAssertion` rules: HS256 only, the audience, the issuer when given, and the token's lifetime with 30 seconds of clock tolerance. ```ts title="app/api/summary/route.ts (the downstream service)" import { withAssertion } from '@better-iam/next/edge'; export const runtime = 'edge'; export const GET = withAssertion( { key: process.env.IAM_ASSERTION_KEY!, audience: 'reports', authorize: (claims) => claims.mfa }, async (request, { claims }) => Response.json({ tenant: claims.tid, user: claims.sub }), ); ``` | Option | Meaning | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | `key` | `iam.assertionKey()` (64 hex characters), or the list from `iam.assertionKeys()` while the secret rotates | | `audience` | This service's audience; tokens for any other audience are rejected | | `issuer` | The IAM origin (`iss`), checked when set | | `toleranceSeconds` | Clock skew allowance, default 30 | | `authorize` | `withAssertion` only: extra checks after verification, such as `claims.mfa` or a role. Returning false answers 403 `ACCESS_DENIED` | A missing bearer token answers 401 `UNAUTHENTICATED`; a bad signature, audience, issuer, or lifetime answers 401 `INVALID_ASSERTION` (the `AssertionError` that `verifyAssertionToken` throws). The claims are `iss`, `sub`, `aud`, `iat`, `exp`, `jti`, `tid` (the tenant), `kind`, `mfa`, `method?`, `impersonatorId?`, `name`, `email?`, `roles`, `groups`, and `ext?`. Keep the TTL short: the downstream service cannot see a revocation before the assertion expires. [Secrets](/docs/operations/deployment/secrets) covers rotating the key. ### Receiving your own webhooks [#receiving-your-own-webhooks] Changes also happen outside your pages: an administrator deletes an identity in the console, a SCIM connector deactivates someone, a certification revokes access. Webhooks tell your application so it can clean up its own data. `createWebhookHandler({ secret, onEvent })` is a route handler for your deployment's webhooks that checks the signature and freshness before your code runs: ```ts title="app/api/webhooks/iam/route.ts" import { createWebhookHandler } from '@better-iam/next/edge'; export const POST = createWebhookHandler({ // During rotation, list the new and the previous secret. secret: [process.env.IAM_WEBHOOK_SECRET!, process.env.IAM_WEBHOOK_SECRET_PREVIOUS!].filter(Boolean), onEvent: async (event, delivery) => { if (event.type === 'identity:delete') await removeProfile(event.resourceId); }, }); ``` * **Signatures.** It verifies `X-Better-IAM-Signature` (`v1=` over `{timestamp}.{body}`) against every configured secret, so you can rotate by listing the new and the previous secret. Unsigned requests, and requests whose signature matches no secret, answer 401 `INVALID_SIGNATURE`. * **Limits.** It rejects timestamps more than five minutes from the current time (`toleranceSeconds`, default 300\) and bodies over 1 MiB (`maxBodyBytes`, 413 `PAYLOAD_TOO_LARGE`) before parsing. Methods other than POST answer 405, and a body that is not a JSON event answers 400 `INVALID_INPUT`. * **Retries.** It answers 500 `HANDLER_FAILED` when `onEvent` throws, and Better IAM retries with exponential backoff. Deliveries can repeat after a timeout, so make `onEvent` idempotent on `event.id` (or `delivery.deliveryId`). * **Ordering.** Events carry `sequence` and `hash` from the audit chain, so a consumer can detect gaps. `delivery` holds `deliveryId`, `webhookId`, `event`, and `timestamp` from the delivery headers. `verifyWebhook({ secret, timestamp, body, signature, toleranceSeconds?, now? })` is the same check as the server's `verifyWebhookSignature`, on Web Crypto, for your own handlers. See [Webhooks](/docs/guides/events/webhooks) for subscriptions and [Audit chain](/docs/guides/events/audit-chain) for `sequence` and `hash`. ## Background work [#background-work] Better IAM does not send email, SMS, or webhooks inside the request that caused them. It writes them to an outbox in the same transaction, and events go to a dispatcher, so a slow mail provider never slows down or breaks a sign-in. Something then has to run the outbox and the dispatcher. On a long-lived Node server a `setInterval` is enough. On Vercel and other serverless hosts there is no process left running between requests, so turn on `dispatchAfterResponse`: ```ts title="lib/iam-next.ts" // Email, SMS, webhooks, and events go out after each response (Next's after()). export const iamNext = createIamNext(iam, { dispatchAfterResponse: true }); ``` Every in-process `client()` and `pages.client()` call, and every POST to the `handlers()` or `pages.handler()` mount, then schedules one dispatch that runs once the response has been sent. A sign-in code or reset email leaves within the same invocation, with no separate worker. * **Concurrency.** Runs are single-flight per instance, with the outbox and events on separate lanes, so a slow event handler cannot hold up email. A call made during a run waits for it and triggers exactly one more. An outbox run older than the server's 60-second claim lease no longer holds back new ones. * **Fallback.** Where `after()` cannot run (the Pages Router, or outside a request scope), the run starts immediately without being awaited. * **Re-entrancy.** Inside an event handler or delivery callback, call `background.schedule()` rather than awaiting `dispatch()`. Where `AsyncLocalStorage` is available (Next provides it on Node and edge), awaiting `dispatch()` there resolves at once with `{ deferred: true }` and queues the follow-up run. `iamNext.background` also has `dispatch()`, which runs a pass now and resolves with `{ outbox?: { delivered, failed, abandoned, rounds }, events?: { dispatched } }`. Its `schedule()` queues a pass after the response and never throws. The `background` option of `createIamNext` takes: * `onError(error, task)`: receives failures nobody awaits (default `console.error`). * `after`: an `after()` override. * `outboxLimit`: messages per round, 1 to 1000 (default 100). * `maxRounds`: rounds per pass (default 10); another round runs only while the last one was full. ### Scheduled jobs [#scheduled-jobs] `background.cron(options)` covers everything interactive traffic does not trigger: retries with backoff, quiet periods, and the periodic jobs. ```ts title="app/api/cron/route.ts" import { iamNext } from '@/lib/iam-next'; export const runtime = 'nodejs'; export const GET = iamNext.background.cron({ secret: process.env.CRON_SECRET, tasks: { purge: { retentionMs: 30 * 86_400_000 }, auditRetention: { retentionMs: 365 * 86_400_000 }, digest: true, reminders: true, outbox: true, events: true, }, }); ``` On Vercel, schedule it in `vercel.json`: ```json title="vercel.json" { "crons": [{ "path": "/api/cron", "schedule": "*/10 * * * *" }] } ``` | Task | Runs | Default | | ---------------- | ---------------------------------------------------------------------------------------------------------- | ------- | | `outbox` | Delivers queued outbox messages, repeated while batches come back full | on | | `events` | Dispatches pending events to plugins, `events.onEvent`, and subscribers | on | | `purge` | `purgeDeleted`: expires lapsed access and removes deleted tenants past retention (`{ retentionMs? }`) | off | | `auditRetention` | `pruneAudit` for every tenant that is not deleted, or the `tenants` you list (`{ retentionMs, tenants? }`) | off | | `digest` | `sendAccessDigest` with the options you pass | off | | `reminders` | `sendExpiryReminders` with the options you pass | off | * **Authentication.** The route requires `Authorization: Bearer ` (Vercel Cron sends `CRON_SECRET` this way; `secret` defaults to `process.env.CRON_SECRET`). It compares in constant time and refuses to run anything when no secret is configured (500 `CRON_NOT_CONFIGURED`). * **Order.** Jobs run first, then the outbox drain and events, so the email a job queues leaves in the same run. * **Results.** Each task is isolated. The response lists per-task `results` and `errors`, and is 500 if any task failed. Unexpected failures read `TASK_FAILED` without internal detail and go to `background.onError`. `eventsWaitMs` (default 10 000) bounds how long the events task waits for a dispatch already running in the process before failing with `BUSY`. [Jobs](/docs/operations/jobs) covers the same jobs for other hosts. ## Pages Router [#pages-router] Applications that still use `pages/` (or mix both routers) get the same guarantees through `iamNext.pages`. The Pages Router hands you Node `req`/`res` objects and `getServerSideProps` instead of server components, so these helpers take those shapes: | Helper | Use | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pages.handler()` | `pages/api/iam/[...path].ts`. It re-encodes bodies Next already parsed, so `bodyParser` can stay on; turn it off only for byte-exact protocol callbacks | | `pages.withSession(gssp, spec)` | `getServerSideProps`: a login redirect with `?next=`, `notFound` (or `authorize.redirectTo`) on denial, and the session in props | | `pages.api(handler, spec)` | API routes: session cookie or bearer session token, `stepUp`, `authorize`, the JSON error envelope, JSON results, and 204 for `undefined` | | `pages.client(req, res)` | The in-process typed client. Issued cookies are appended to `res`, so API routes can sign people in and out | | `pages.getSession(req)` | The session for any Node request with `headers`, or `null` | **API mount:** ```ts title="pages/api/iam/[...path].ts" import { iamNext } from '@/lib/iam-next'; export default iamNext.pages.handler(); ``` **getServerSideProps:** ```tsx title="pages/projects/[id].tsx" export const getServerSideProps = iamNext.pages.withSession( async (context, { session }) => ({ props: { id: String(context.params?.id) } }), { authorize: { action: 'projects:read', resource: ({ params }) => ({ type: 'project', id: String(params.id) }), }, }, ); export default function Project({ id, session }: { id: string; session: { identity: { name: string } } }) { return (

{id} for {session.identity.name}

); } ``` **API route:** ```ts title="pages/api/projects/[id].ts" export default iamNext.pages.api(async (req, res, { session }) => ({ id: req.query.id }), { authorize: { action: 'projects:read', resource: ({ req }) => ({ type: 'project', id: String(req.query.id) }), }, }); ``` * `withSession` redirects signed-out visitors to the login path with `?next=` (the resolved URL) and sessions that must step up to the step-up page. It serializes the session through JSON, without its token hash, before adding it to the props, because Next requires serializable props. Its `authorize` callbacks receive `{ session, params, query }`, and `gssp` is optional. * `pages.api` applies the same origin check to cookie mutations as `route()`. Results are sent as JSON unless the handler already responded. * `pages.handler()` accepts parsed JSON and form bodies and streams unparsed ones, up to 2 MiB. ## Runtime notes [#runtime-notes] These rules apply to every `@better-iam/next` helper, on this page and the others: * Route handlers that touch `iam` need `export const runtime = 'nodejs'`. The edge helpers run anywhere. * `iam.endpoint` gives `client()` the origin and base path. A custom `IamLike` without it can pass `baseURL` and `basePath` options; otherwise the origin is derived from `x-forwarded-host` / `host`, and it must be one of the server's trusted origins. * The helpers never cache across requests. Memoization is scoped to one render, and there is no module-level session state. * Only `IamError` and `IamClientError` become responses or `ActionResult`s. Other errors and Next control flow (`redirect()`, `notFound()`, `forbidden()`, `unauthorized()`) propagate unchanged in every wrapper; `isNextControlError(error)` and `isAuthenticationError(error)` are exported for your own wrappers. ## Next steps [#next-steps] - [Sessions](/docs/guides/authentication/sessions): Recent authentication, lifetimes, and revocation on the server. - [Jobs](/docs/operations/jobs): The maintenance jobs `background.cron()` runs, for other hosts. - [Webhooks](/docs/guides/events/webhooks): Subscriptions, event types, and delivery retries. # Guards (/docs/frameworks/nextjs/guards) > Protect Next.js pages, layouts, and route handlers with page, requireSession, require, and route, and render by permission with batched checks. Guards run on the server before anything renders or responds. Each one authenticates the request, then checks step-up ([details](/docs/frameworks/nextjs/advanced#step-up)) when you ask for it, then authorizes, and only then calls your code. Decisions come from `iam.require`, so policies, boundaries, conditions, relationships, and audit behave exactly as they do everywhere else. ## Pages and layouts [#pages-and-layouts] `iamNext.page(render, spec)` wraps a page or layout. It requires a session, optionally a step-up, optionally enforces an action, and then calls `render(props, { session, params })` with the params already resolved. ```tsx title="app/[org]/documents/[id]/page.tsx" import { iamNext } from '@/lib/iam-next'; // Denied visitors see app/forbidden.tsx through Next's forbidden() (interrupts: 'forbidden'). export default iamNext.page( async (_props: { params: Promise<{ org: string; id: string }> }, { session, params }) => (

Document “{params.id}”

{session.identity.name} holds documents:read on document/{params.id}.

), { authorize: { action: 'documents:read', resource: ({ params }) => ({ type: 'document', id: params.id }), }, }, ); ``` | Spec field | Meaning | | ---------------------- | ------------------------------------------------------------------------------------------------- | | `authorize.action` | The action to enforce | | `authorize.resource` | `({ session, params }) => ({ type, id })`; defaults to the tenant itself (`iam/{tenantId}`) | | `authorize.tenantId` | `({ session, params }) => tenantId`; defaults to the session's tenant | | `authorize.redirectTo` | Where denied visitors go; without it the denial throws (or calls `forbidden()` with `interrupts`) | | `stepUp` | `{ mfa?: true \| 'fresh', maxAgeMs?, redirectTo? }`, checked after sign-in and before `authorize` | | `returnTo` | `(params) => path` for `?next=`; defaults to the path the middleware forwarded | | `loginRedirect` | Where signed-out visitors go instead of `loginPath` | Pass the render function first and the spec second. TypeScript then infers the params from the render function's annotation and checks the `resource` callback against them. ### Lower-level helpers [#lower-level-helpers] `requireSession({ headers?, redirectTo?, returnTo?, stepUp? })` returns the session or redirects to the login page with `?next=`. `require({ tenantId, action, resource, headers?, redirectTo? })` enforces one action before rendering: denial redirects to `redirectTo` when given and throws otherwise. ```tsx title="app/settings/page.tsx" export default async function Settings() { const session = await iamNext.requireSession(); await iamNext.require({ tenantId: session.session.tenantId, action: 'iam:identities:update', resource: { type: 'iam', id: session.session.tenantId }, redirectTo: '/forbidden', }); return ; } ``` ## What each guard does on failure [#what-each-guard-does-on-failure] Each guard answers a refusal the way its context allows: pages redirect or interrupt, route handlers answer JSON, and server actions return a result the form can show. | Helper | Signed out | Step-up missing | Denied | Other IAM errors | | ---------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------- | | `requireSession()` / `page()` | redirect to `loginPath?next=` (or `unauthorized()`) | redirect to `stepUpPath?next=&reason=`, else throw ([details](/docs/frameworks/nextjs/advanced#without-a-step-up-page)) | `authorize.redirectTo`, else throw (or `forbidden()`) | thrown | | `require({ ..., redirectTo })` | `redirectTo`, else throw (or `unauthorized()`) | not applicable | `redirectTo`, else throw (or `forbidden()`) | thrown | | `route()` / `apiRoute()` / `pages.api()` | `401` JSON envelope | `403` with `MFA_REQUIRED`, `RECENT_AUTH_REQUIRED`, or `IMPERSONATION_RESTRICTED` | `403` JSON envelope | JSON envelope with the server's status | | `action(fn, spec)` | `{ ok: false, error: { code: 'UNAUTHENTICATED' } }` | `{ ok: false, error }` with `MFA_REQUIRED`, `RECENT_AUTH_REQUIRED`, or `IMPERSONATION_RESTRICTED` | `{ ok: false, error: { code: 'ACCESS_DENIED' } }` | `{ ok: false, error }` | Route handlers and server actions never redirect or interrupt; they always report through their response. [Server actions](/docs/frameworks/nextjs/server-actions) covers `action()`, and [Advanced](/docs/frameworks/nextjs/advanced#pages-router) the Pages Router. ### Auth interrupts [#auth-interrupts] "(or ...)" in the table applies with `interrupts: true`, which uses Next's `unauthorized()` and `forbidden()`. Enable `experimental.authInterrupts` in `next.config` and add `unauthorized.tsx` and `forbidden.tsx`. Without the flag those calls throw an error in Next 15. `interrupts: 'forbidden'` interrupts denials only, so signed-out visitors are still redirected to the login page. This is the usual choice, and the example app uses it: ```tsx title="app/forbidden.tsx" import Link from 'next/link'; export default function Forbidden() { return (

403 · Not allowed

Your account is signed in but no policy grants this action.

Back home
); } ``` ## Route handlers [#route-handlers] `iamNext.route(handler, spec)` wraps an App Router route handler. It authenticates the request with a session cookie or a bearer session token, applies `stepUp` and `authorize`, and turns the handler's result into a response. ```ts title="app/api/documents/[id]/route.ts" import { iamNext } from '@/lib/iam-next'; export const runtime = 'nodejs'; export const GET = iamNext.route<{ id: string }>( async (_request, { session, params }) => ({ id: params.id, reader: session.identity.email, tenantId: session.session.tenantId, }), { authorize: { action: 'documents:read', resource: ({ params }) => ({ type: 'document', id: params.id }), }, }, ); ``` * **Results.** A plain value is sent as JSON with `Cache-Control: no-store`, `undefined` answers `204`, and a `Response` passes through unchanged. * **Errors.** Only `IamError` and `IamClientError` become the JSON error envelope (`{ error: { code, message } }`) with the server's status: 401, 403, 429, and so on. Other errors, even ones with a `code` field such as `ENOENT`, and Next control flow (`redirect()`, `notFound()`) propagate unchanged, so internal messages never reach a client. * **Resource and tenant callbacks** receive `{ session, params, request }`. * **Machine callers.** `route()` accepts user sessions only. `apiRoute()` also accepts API keys and assumed-role sessions and hands the handler a sanitized principal; see [Service credentials](/docs/frameworks/nextjs/advanced#service-credentials). ### Cross-site requests [#cross-site-requests] Browsers attach the session cookie to requests on their own, including form posts started by other pages. The cookie is `SameSite=Lax`, which still sends it with posts from sibling subdomains, so without a check a page on another subdomain could change data as the signed-in person. The wrappers therefore check where cookie requests come from. Cookie-authenticated mutations to `route()`, `apiRoute()`, and `pages.api()` must come from this application (a matching `Origin`, or `Sec-Fetch-Site: same-origin`), the IAM origin, or an origin listed in `trustedOrigins`. That covers every method other than GET, HEAD, and OPTIONS that carries the session cookie and no `Authorization` header. Anything else is refused with `CSRF_REJECTED` (no `Origin`) or `UNTRUSTED_ORIGIN` (403) before the handler runs, the same boundary the IAM handler draws. Server actions are exempt because Next checks their origin itself. ## Rendering by permission [#rendering-by-permission] Advisory checks decide what to render: which buttons, links, and badges appear. Enforce the mutation itself with `action()`, `route()`, or the API. **iamNext.Can:** ```tsx Read-only: no documents:write on the roadmap.

} >

You may edit the roadmap.

``` An async server component. It renders `children` when the current session may perform the action and `fallback` otherwise. `tenantId` defaults to the session's tenant. **allowed():** ```ts const canInvite = await iamNext.allowed('iam:identities:create'); // tenant defaults to the session's const canEdit = await iamNext.allowed('documents:write', { type: 'document', id }, { tenantId }); ``` One boolean per call; signed-out requests get `false` without a call. **can():** ```tsx const access = await iamNext.can({ tenantId: session.session.tenantId, checks: [ { action: 'documents:read', resource: { type: 'document', id: 'roadmap' } }, { action: 'documents:write', resource: { type: 'document', id: 'roadmap' } }, { action: 'iam:identities:read' }, // the tenant itself ], }); access['documents:write@document/roadmap']; // true or false ``` One `authorizeMany` call. The result is keyed `${action}@${type}/${id}`, and every key is `false` when the request is not authenticated. `allowed()` and `` batch per request. Checks made while a render is in flight wait for one macrotask, so sibling server components reach their checks after their own awaits. Then they go out as a single deduplicated `authorizeMany`: 50 checks per call, one queue per tenant. A page full of permission-dependent buttons costs one round trip, and answers are reused for the rest of the render. ## Next steps [#next-steps] - [Server actions and forms](/docs/frameworks/nextjs/server-actions): Guarded mutations with `useActionState`, and sign-in without client JavaScript. - [Step-up](/docs/frameworks/nextjs/advanced#step-up): Require MFA or a recent sign-in for sensitive pages. - [Policies](/docs/guides/authorization/policies): What `authorize` actually evaluates. # Next.js (/docs/frameworks/nextjs) > Set up @better-iam/next in an App Router project, mount the IAM API, and read the session in server components, layouts, and client components. `@better-iam/next` integrates Better IAM with the Next.js 15 App Router (React 19). The App Router spreads a request across server components, layouts, route handlers, server actions, and middleware, and each exposes the request differently: `headers()` and `cookies()` in components, a `Request` in route handlers, form data in actions, and an edge runtime without database access in middleware. Calling the core API directly would mean building the credential, redirecting with a safe `?next=`, mapping refusals to status codes, and writing session cookies by hand in each of those places. One `createIamNext(iam)` call does it once and gives your server code: * **Sessions in server components**: `getSession()` and `requireSession()`, memoized per request. * **Guards**: `page()` for pages and layouts, `route()` and `apiRoute()` for route handlers, `action()` for server actions, and `require()` for one-off checks. * **Rendering by permission**: `can()`, `allowed()`, and the `` server component, batched into one `authorizeMany` per request. * **A server-side typed client**: `client()` calls the IAM handler in process and writes the cookies it issues, so server actions can sign people in and out without client JavaScript. * **Cross-site checks**: `route()`, `apiRoute()`, and `pages.api()` refuse cookie-authenticated mutations sent by another site's page ([details](/docs/frameworks/nextjs/guards#cross-site-requests)). Next checks server actions itself. * **Hydration**: `sessionForClient()` hands the session to `IamNextProvider`, so client components render the signed-in person on the first pass, matching the server's HTML ([Client components](#client-components)). * **Auth forms**: drop-in server actions and unstyled, accessible forms for sign-in, MFA, step-up, password reset, sign-up, email verification, and invitations. * **Organizations in the URL**, **edge middleware**, **step-up**, **service credentials**, **webhooks and assertions**, **background delivery**, and the **Pages Router**. The package has three entries: | Entry | Use from | Contents | | ------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `@better-iam/next` | Server components, route handlers, server actions | `createIamNext` and everything in the edge entry | | `@better-iam/next/edge` | Middleware, edge route handlers, any Web runtime | `createIamMiddleware`, `safeRedirectPath`, assertion and webhook verification. No Node, React, or database imports, so the edge bundle stays small | | `@better-iam/next/client` | Client components (`'use client'`) | `IamNextProvider`, `useSignOut`, the auth forms, and the React hooks | The umbrella package exposes the same entries as `better-iam/next`, `better-iam/next/edge`, and `better-iam/next/client`. #### Every export, by entry **`@better-iam/next`** (also re-exports everything from the edge entry) | Export | What it does | | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `createIamNext(iam, options)` | Creates the `iamNext` helpers described on these pages | | `checkStepUp(session, requirement, now?)` | Checks a session (or an `apiRoute` principal) against a step-up requirement; returns `null` or the failure | | `isAuthenticationError(error)` | True for the errors `getSession` treats as "signed out" | | `isNextControlError(error)` | True for Next's `redirect()`, `notFound()`, `forbidden()`, and `unauthorized()` throws, which must propagate | | `parseSetCookie(header)` | Parses a `Set-Cookie` header into the arguments `cookies().set` takes | | `createAuthActions`, `authHiddenFields`, `authFields`, `authStepFields` | The building blocks behind `iamNext.authActions()` and the forms, for custom form containers | | `createBackground(resolveIam, options)` | The building block behind `iamNext.background`, for instances you manage yourself | **`@better-iam/next/edge`** | Export | What it does | | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `createIamMiddleware(options)` | Edge middleware: redirects visitors without a session cookie and forwards the path ([Middleware](/docs/frameworks/nextjs/middleware)) | | `safeRedirectPath(value, fallback?)` | Returns a same-origin path, or the fallback, so `?next=` cannot become an open redirect | | `matchPath(pattern, pathname)` | The `*` / `**` glob matcher behind `publicPaths` | | `sessionCookieName(secure)` | `__Host-better-iam.session` on HTTPS, `better-iam.session` otherwise | | `pathnameHeader` | The name of the forwarded-path header, `x-better-iam-pathname` | | `verifyAssertionToken(token, options)`, `withAssertion(options, handler)`, `AssertionError` | Verify assertions offline in another service ([Assertions](/docs/frameworks/nextjs/advanced#assertions-for-other-services)) | | `createWebhookHandler(options)`, `verifyWebhook(input)` | Receive and verify your deployment's webhooks ([Webhooks](/docs/frameworks/nextjs/advanced#receiving-your-own-webhooks)) | **`@better-iam/next/client`** | Export | What it does | | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `IamNextProvider` | `IamProvider` that refreshes server components when the signed-in identity changes | | `useRouterSync()` | The hook inside `IamNextProvider`, for a provider tree you assemble yourself | | `useSignOut({ redirectTo? })` | Returns a function that signs out, navigates, and refreshes server components | | `SignInForm`, `ReauthenticateForm`, `PasswordResetRequestForm`, `PasswordResetForm`, `SignUpForm`, `InvitationForm` and their `*View` twins | The auth forms ([Server actions and forms](/docs/frameworks/nextjs/server-actions#the-forms)) | | `useSession`, `useAuthorize`, `useAccessible`, `useIamClient`, `Can` | Re-exported from [`@better-iam/react`](/docs/frameworks/react) | ## Install [#install] npm pnpm yarn bun ```bash npm i @better-iam/next @better-iam/client @better-iam/server @better-iam/adapter-sqlite ``` ```bash pnpm add @better-iam/next @better-iam/client @better-iam/server @better-iam/adapter-sqlite ``` ```bash yarn add @better-iam/next @better-iam/client @better-iam/server @better-iam/adapter-sqlite ``` ```bash bun add @better-iam/next @better-iam/client @better-iam/server @better-iam/adapter-sqlite ``` Or install the umbrella package, `better-iam`, and import from its subpaths. See [Installation](/docs/guides/installation) for other storage adapters. ## Application layout [#application-layout] A complete App Router setup spreads over these files. The instance, the helpers, and the API route are the minimum; the others add edge redirects, drop-in forms, and background delivery. - `route.ts` - `route.ts` - `route.ts` - `layout.tsx` - `page.tsx` - `page.tsx` - `auth-actions.ts` - `layout.tsx` - `providers.tsx` - `iam.ts` - `iam-next.ts` - `middleware.ts` - `next.config.mjs` | File | Holds | Runtime | | -------------------------------- | ------------------------------ | ----------------------------------------------------- | | `lib/iam.ts` | `betterIam({ ... })` | Node only (database, argon2) | | `lib/iam-next.ts` | `createIamNext(iam, options)` | Server components, actions, route handlers | | `app/api/iam/[...path]/route.ts` | `iamNext.handlers()` | The browser client, OAuth/SAML/SCIM mounts, `/health` | | `middleware.ts` | `createIamMiddleware(...)` | Edge: cookie presence and path forwarding | | `app/auth-actions.ts` | `iamNext.authActions()` | Sign-in, step-up, and reset server actions | | `app/api/webhooks/iam/route.ts` | `createWebhookHandler(...)` | Optional: consume your own IAM events | | `app/api/cron/route.ts` | `iamNext.background.cron(...)` | Optional: scheduled delivery and maintenance | ## Setup [#setup] ### Create the instance [#create-the-instance] ```ts title="lib/iam.ts" import { betterIam } from '@better-iam/server'; import { sqliteAdapter } from '@better-iam/adapter-sqlite'; export const iam = betterIam({ database: sqliteAdapter({ filename: process.env.BETTER_IAM_DATABASE ?? '.data/iam.db' }), secret: process.env.BETTER_IAM_SECRET!, baseURL: process.env.BETTER_IAM_BASE_URL ?? 'http://localhost:3000', permissions: { actions: ['documents:read', 'documents:write'] }, }); ``` `baseURL` matters here: it becomes `iam.endpoint.origin`, the origin the in-process client presents and the one the server trusts for cookie requests. ### Create the Next.js helpers [#create-the-nextjs-helpers] `createIamNext` wraps the instance in the helpers your pages, route handlers, and actions use. `loginPath` and `stepUpPath` are where guards send people who must sign in or confirm who they are. ```ts title="lib/iam-next.ts" import { createIamNext } from '@better-iam/next'; import { iam } from './iam'; export const iamNext = createIamNext(iam, { loginPath: '/login', stepUpPath: '/reauth', interrupts: 'forbidden', // denials render app/forbidden.tsx }); ``` ### Mount the IAM HTTP API [#mount-the-iam-http-api] The browser client (including passkey ceremonies) and the OAuth, SAML, and SCIM protocol mounts reach the server through this catch-all route. ```ts title="app/api/iam/[...path]/route.ts" import { iamNext } from '@/lib/iam-next'; export const runtime = 'nodejs'; export const { GET, POST, OPTIONS } = iamNext.handlers(); ``` GET serves the operational `/health` and `/metrics` endpoints; every API method is a POST. ### Keep native modules out of the bundle [#keep-native-modules-out-of-the-bundle] The server and its native dependencies (`argon2`, `better-sqlite3`) must load as plain Node modules. List them in `serverExternalPackages`, or load the instance lazily and pass a factory instead of the instance. **serverExternalPackages:** ```js title="next.config.mjs" /** @type {import('next').NextConfig} */ export default { serverExternalPackages: [ '@better-iam/server', '@better-iam/auth', '@better-iam/adapter-sqlite', 'argon2', 'better-sqlite3', ], experimental: { authInterrupts: true }, // only for interrupts: true or 'forbidden' }; ``` **Lazy factory:** ```ts title="lib/iam.ts" import type { BetterIam } from '@better-iam/server'; import { createIamNext } from '@better-iam/next'; async function create(): Promise { // Loaded at runtime from node_modules, never bundled. const [{ betterIam }, { sqliteAdapter }] = await Promise.all([ import(/* webpackIgnore: true */ '@better-iam/server'), import(/* webpackIgnore: true */ '@better-iam/adapter-sqlite'), ]); const iam = betterIam({ database: sqliteAdapter({ filename: process.env.BETTER_IAM_DATABASE ?? '.data/iam.db' }), secret: process.env.BETTER_IAM_SECRET!, baseURL: process.env.BETTER_IAM_BASE_URL ?? 'http://localhost:3000', }); await iam.initialize(); return iam; } // One instance per process: Next may evaluate this module once per server layer. const holder = globalThis as unknown as { __iam?: Promise }; export function getIam(): Promise { holder.__iam ??= create(); return holder.__iam; } export const iamNext = createIamNext(getIam, { loginPath: '/login' }); ``` `serverExternalPackages` only applies to packages resolved from `node_modules`. In a monorepo, workspace packages are symlinks outside it, so the example app and the console load the server with `import(/* webpackIgnore: true */ ...)` instead. ### Protect routes at the edge [#protect-routes-at-the-edge] Add [middleware](/docs/frameworks/nextjs/middleware) that redirects visitors without a session cookie and forwards the requested path, so `?next=` fills itself. ```ts title="middleware.ts" import { NextResponse } from 'next/server'; import { createIamMiddleware } from '@better-iam/next/edge'; export const middleware = createIamMiddleware({ loginPath: '/login', publicPaths: ['/', '/pricing', '/docs/**', '/invite/*'], next: (init) => NextResponse.next(init), }); export const config = { matcher: ['/((?!_next|favicon.ico).*)'] }; ``` ## Reading the session [#reading-the-session] ```tsx title="app/dashboard/page.tsx" import { iamNext } from '@/lib/iam-next'; export default async function Dashboard() { const current = await iamNext.getSession(); if (!current) return Sign in; return

Welcome, {current.identity.name}

; } ``` `iamNext.getSession()` reads the request's cookies (through `headers()` and `cookies()`) and returns `{ identity, session }`. It returns `null` whenever the server refuses the credential as a sign-in. That covers missing, expired, or revoked sessions, sessions that must step up, unverified email addresses, inactive tenants, and requests from a network the session or tenant does not allow. Any other failure is rethrown. `requireSession()` returns the session or redirects to the login page with `?next=`; [Guards](/docs/frameworks/nextjs/guards) covers it with the other wrappers. * **Per-request memoization.** The no-argument call goes through React `cache`, so a layout, its page, and nested server components share one lookup per render. Calls that pass `headers` explicitly are not memoized. The `cache` option replaces React's; pass `(fn) => fn` to disable it. * **After a server action.** The helper merges `cookies()` into the headers it sends. Cookies a server action just set, such as a new session after sign-in, therefore apply to the re-render Next performs in the same response. * **Direct API calls.** `iamNext.credential()` returns `{ headers }` for any `iam.api.*` call made as the current visitor: `iam.api.identities.list(await iamNext.credential(), { tenantId })`. * **No cross-request state.** The helpers never cache across requests. Memoization is scoped to one render, and there is no module-level session state. ### Client components [#client-components] Client components get the session from the server so their first render needs no fetch. `iamNext.sessionForClient()` returns the session as plain JSON without the stored token hash, or `null`. Pass it to `IamNextProvider`, which is `IamProvider` plus a `router.refresh()` whenever the signed-in identity changes in the browser ([details](/docs/frameworks/nextjs/advanced#keeping-server-components-in-sync)). **app/layout.tsx:** ```tsx title="app/layout.tsx" import type { ReactNode } from 'react'; import { iamNext } from '@/lib/iam-next'; import { Providers } from './providers'; export default async function RootLayout({ children }: { children: ReactNode }) { return ( {children} ); } ``` **app/providers.tsx:** ```tsx title="app/providers.tsx" 'use client'; import type { ReactNode } from 'react'; import { createIamClient } from '@better-iam/client'; import { IamNextProvider } from '@better-iam/next/client'; import type { BetterIam } from '@better-iam/server'; // Never called during server rendering (the provider starts from initialSession), but it needs an origin. const client = createIamClient({ baseURL: typeof window === 'undefined' ? 'http://localhost' : window.location.origin, }); type Session = Awaited>; export function Providers(props: { initialSession: Session | null; children: ReactNode }) { return ( {props.children} ); } ``` Inside the provider, the [React hooks](/docs/frameworks/react) (`useSession`, `useAuthorize`, `useAccessible`, `useIamClient`, `Can`) work as usual; `@better-iam/next/client` re-exports them. ## Options [#options] `createIamNext(iam, options)` takes these options. Most applications set `loginPath`, `stepUpPath`, and `interrupts`; `headers`, `cookies`, `redirect`, and the other overrides exist for tests and custom setups. `createIamNext` accepts the instance or a factory (`() => iam` or `() => Promise`). Any object with the `IamLike` surface works; a custom one without `endpoint` passes `baseURL` and `basePath`. ## `iamNext` at a glance [#iamnext-at-a-glance] Everything `createIamNext` returns, and where each member is explained: | Member | What it does | Details | | ------------------------------------------------ | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | | `getSession(headers?)` | The current `{ identity, session }`, or `null`; memoized per request | [Reading the session](#reading-the-session) | | `requireSession({ ... })` | The session, or a redirect to the login page (or the step-up page) | [Guards](/docs/frameworks/nextjs/guards#lower-level-helpers) | | `require({ tenantId, action, resource })` | Enforces one action before rendering | [Guards](/docs/frameworks/nextjs/guards#lower-level-helpers) | | `page(render, spec)` | Wraps a page or layout: session, step-up, authorization, then render | [Guards](/docs/frameworks/nextjs/guards#pages-and-layouts) | | `route(handler, spec)` | Wraps a route handler for user sessions; answers IAM failures as JSON | [Guards](/docs/frameworks/nextjs/guards#route-handlers) | | `apiRoute(handler, spec)` | `route()` that also accepts API keys and assumed roles | [Service credentials](/docs/frameworks/nextjs/advanced#service-credentials) | | `action(fn, spec)` | Wraps a server action; returns `{ ok, data }` or `{ ok: false, error }` | [Server actions](/docs/frameworks/nextjs/server-actions#guarded-mutations) | | `can({ tenantId, checks })` | Several advisory decisions in one call, keyed `action@type/id` | [Rendering by permission](/docs/frameworks/nextjs/guards#rendering-by-permission) | | `allowed(action, resource?)`, `` | One advisory decision, batched with every other check in the render | [Rendering by permission](/docs/frameworks/nextjs/guards#rendering-by-permission) | | `client()` | The typed API, running in process, writing issued cookies through `cookies()` | [In-process client](/docs/frameworks/nextjs/server-actions#the-in-process-client) | | `authActions(options)` | Drop-in server actions for sign-in, step-up, reset, sign-up, verification, and invitations | [Auth forms](/docs/frameworks/nextjs/server-actions#auth-forms) | | `handlers()` | `GET`, `POST`, and `OPTIONS` for the `app/api/iam/[...path]` route | [Setup](#setup) | | `sessionForClient()` | The session as plain JSON (no token hash) for `IamNextProvider` | [Client components](#client-components) | | `requireTenantSession({ slug })`, `tenant(slug)` | Resolve an organization alias and require a session in it | [Organizations](/docs/frameworks/nextjs/organizations) | | `assertion({ tenantId, audience })` | A short-lived signed assertion for a downstream service | [Assertions](/docs/frameworks/nextjs/advanced#assertions-for-other-services) | | `credential()` | `{ headers }` for direct `iam.api.*` calls as the current visitor | [Reading the session](#reading-the-session) | | `currentPath()` | The path the middleware forwarded for this request, when present and safe | [Middleware](/docs/frameworks/nextjs/middleware) | | `clearSessionCookie()` | Expires the session cookie in the current response | [Server actions](/docs/frameworks/nextjs/server-actions#the-in-process-client) | | `background` | Outbox and event dispatch: `dispatch()`, `schedule()`, and the `cron()` route | [Background work](/docs/frameworks/nextjs/advanced#background-work) | | `pages` | Pages Router helpers: `handler`, `withSession`, `api`, `client`, `getSession` | [Pages Router](/docs/frameworks/nextjs/advanced#pages-router) | ## In this section [#in-this-section] - [Guards](/docs/frameworks/nextjs/guards): `page`, `requireSession`, `require`, `route`, and rendering by permission. - [Server actions and forms](/docs/frameworks/nextjs/server-actions): The in-process client, guarded actions, and the drop-in auth forms. - [Organizations in the URL](/docs/frameworks/nextjs/organizations): `requireTenantSession` for `/[org]/...` routes. - [Middleware](/docs/frameworks/nextjs/middleware): Edge redirects, public paths, and `?next=` forwarding. - [Advanced](/docs/frameworks/nextjs/advanced): Step-up, service credentials, webhooks, assertions, background work, and the Pages Router. The [example app](https://github.com/better-iam/better-iam/tree/main/examples/nextjs) (`examples/nextjs`) runs all of it on port 3300 with seeded accounts. # Middleware (/docs/frameworks/nextjs/middleware) > Redirect signed-out visitors at the edge with createIamMiddleware, keep public paths open, and forward the requested path so ?next= fills itself. `createIamMiddleware` from `@better-iam/next/edge` sends visitors without a session cookie to the login page before a protected page starts rendering, and forwards the requested path to your server components. It is a routing convenience, not authorization. Why use it: a signed-out visitor is redirected at the edge, before any server component runs or queries anything. Also, App Router server components are not given the full path of the request they render. Without the forwarded path, a guard deep in a layout would not know where to send the person back after sign-in. > **Middleware checks presence, not validity.** Middleware runs on the edge and cannot open the database. It only checks that the session cookie is present: `__Host-better-iam.session` on HTTPS and `better-iam.session` on loopback HTTP. Pages must still authenticate with `requireSession`, `page`, or `requireTenantSession`. A stale cookie gets through the middleware, and the page's guard then redirects. ## Setup [#setup] ```ts title="middleware.ts" import { NextResponse } from 'next/server'; import { createIamMiddleware } from '@better-iam/next/edge'; export const middleware = createIamMiddleware({ loginPath: '/login', publicPaths: ['/', '/pricing', '/docs/**', '/invite/*'], signedInRedirect: '/dashboard', next: (init) => NextResponse.next(init), }); export const config = { matcher: ['/((?!_next|favicon.ico).*)'] }; ``` Import middleware helpers from `@better-iam/next/edge`. That entry has no Node, React, or database imports, so the edge bundle stays small. The main entry re-exports everything in it. ## What it does, per request [#what-it-does-per-request] * **Public paths.** The default rule protects everything except the login path (and paths below it), anything under `/api/iam`, and the `publicPaths` globs. `publicPaths` adds to those defaults; `protect` replaces the whole rule. * **Redirects.** A protected request without the cookie gets a 307 to `loginPath` with `?next=` set to the path and query it asked for. * **Path forwarding.** With `next`, every request that continues carries `x-better-iam-pathname`. `requireSession`, `page`, and `requireTenantSession` use it as the default `?next=`, so a deep link survives sign-in without passing `returnTo` by hand. `iamNext.currentPath()` reads it (when present and safe). ## Signed-in redirects and stale cookies [#signed-in-redirects-and-stale-cookies] `signedInRedirect` sends visitors who carry a cookie away from a bare login page, for example to the dashboard. A login URL with `?next=` always renders. That rule is what keeps a stale cookie from looping. When a server guard rejects a request that still carries a session cookie (revoked, expired, or from a reset database), it always attaches `?next=` (at least `/`). The middleware sees `?next=`, lets the login page render, and the visitor signs in again instead of bouncing between the guard and the middleware. ## Safe redirects [#safe-redirects] `safeRedirectPath(value, fallback = '/')` returns `value` only when it is a same-origin path. It rejects absolute URLs, protocol-relative `//host`, backslashes, control characters, values over 2048 characters, and dot segments that normalize into `//host`. Run every `next` parameter through it before redirecting: ```ts import { safeRedirectPath } from '@better-iam/next/edge'; redirect(safeRedirectPath(searchParams.get('next'), '/dashboard')); ``` The auth forms and guards already do this. The edge entry also exports `matchPath(pattern, pathname)`, the glob matcher behind `publicPaths`, and `sessionCookieName(secure)`, which returns the cookie name the middleware looks for. ## Next steps [#next-steps] - [Guards](/docs/frameworks/nextjs/guards): The server-side checks that authenticate and authorize after the middleware lets a request through. - [Organizations in the URL](/docs/frameworks/nextjs/organizations): `requireTenantSession`, which also uses the forwarded path for `?next=`. # Organizations in the URL (/docs/frameworks/nextjs/organizations) > Serve each organization under its own /[org] path with requireTenantSession, and send visitors from other organizations to the right sign-in. Multi-tenant applications often put the organization in the path: `/acme/projects`, `/globex/settings`. The path segment is the organization's alias (its slug), and every page below it should run in that organization's tenant. Two things can go wrong without help: an unknown or deactivated alias renders a broken page instead of a 404, and a person signed in to Acme who follows a link to `/globex` sees Globex pages rendered around an Acme session. `iamNext.requireTenantSession({ slug })` handles both: it resolves the alias and requires a session in that tenant, sending everyone else to sign in to the right organization. ## The organization layout [#the-organization-layout] Put it in `app/[org]/layout.tsx`, and every page below gets the resolved tenant. ```tsx title="app/[org]/layout.tsx" import type { ReactNode } from 'react'; import Link from 'next/link'; import { iamNext } from '@/lib/iam-next'; import { signOut } from '../auth-actions'; export default async function OrgLayout(props: { params: Promise<{ org: string }>; children: ReactNode; }) { const { org } = await props.params; // Unknown aliases 404; visitors signed in elsewhere go to /login?org=...&next=... const { tenant, session } = await iamNext.requireTenantSession({ slug: org }); return ( <>
{tenant.name}
{session.identity.name}
{props.children} ); } ``` `requireTenantSession({ slug, headers?, returnTo?, redirectTo? })` returns `{ tenant, session }`, where `tenant` is `{ tenantId, name, type, slug }`. * **Resolution.** The alias goes through [`tenants.lookup`](/docs/reference/api/tenants#lookup), which finds active tenants with active ancestors only. Unknown or inactive aliases call `notFound()`. * **Wrong organization.** A visitor who is signed out, or signed in to a different organization, is redirected to `/login?org={slug}&next=...` (or `redirectTo`). The login page can pre-select the organization or offer the account switcher. * **Return path.** `?next=` is `returnTo`, or the path the [middleware](/docs/frameworks/nextjs/middleware) forwarded, so a deep link survives sign-in. To resolve an alias without requiring a session, for example on a public landing page, use `iamNext.tenant(slug)`. It returns the same summary, or `null` when the alias does not exist or is inactive. ## Pages below the layout [#pages-below-the-layout] Wrap each page with `iamNext.page` as well: Next.js keeps layouts across client-side navigations instead of re-rendering them, and the page needs the session anyway. Below the layout, the session's tenant is the organization's tenant, which is also the default tenant for `authorize`. ```tsx title="app/[org]/page.tsx" import { iamNext } from '@/lib/iam-next'; export default iamNext.page(async (_props: { params: Promise<{ org: string }> }, { session }) => { const tenantId = session.session.tenantId; const document = { type: 'document', id: 'roadmap' }; const access = await iamNext.can({ tenantId, checks: [ { action: 'documents:read', resource: document }, { action: 'documents:write', resource: document }, { action: 'iam:identities:read' }, ], }); return (

Welcome, {session.identity.name}

Read-only.

}>

You may edit the roadmap.

{JSON.stringify(access, null, 2)}
); }); ``` The layout's `requireTenantSession` and the page's `page()` share one session lookup, because the no-argument session read is memoized per request. ## The login page [#the-login-page] The redirect carries `?org=` and `?next=`. Pass both to the sign-in form: `org` fills (or replaces) the organization field, and `next` is where a completed sign-in returns. ```tsx title="app/login/page.tsx" import { SignInForm } from '@better-iam/next/client'; import { signIn } from '../auth-actions'; export default async function Login(props: { searchParams: Promise<{ next?: string; org?: string }> }) { const { next, org } = await props.searchParams; return ; } ``` Other ways to find the organization at sign-in: * **Email domain.** `iamNext.authActions({ discover: true })` finds the tenant from the verified domain of the email (`domains.discover`) when the form carries no `tenantId` or `org`. See [Enterprise onboarding](/docs/federation/enterprise-onboarding) for verified domains. * **Your own rule.** `authActions({ resolveTenant: async (form) => ... })` picks the tenant from the submission. * **Account switcher.** A person with linked identities in several organizations can list them with [`links.list`](/docs/reference/api/links#list) and switch with [`links.switch`](/docs/reference/api/links#switch). Identities in different tenants stay separate, even when linked; see [Tenants and identities](/docs/guides/concepts/tenants-and-identities). ## Next steps [#next-steps] - [Server actions and forms](/docs/frameworks/nextjs/server-actions): The `SignInForm` and `authActions` options the login page uses. - [Middleware](/docs/frameworks/nextjs/middleware): Forward the requested path so deep links survive sign-in. # Server actions and forms (/docs/frameworks/nextjs/server-actions) > Call Better IAM from Next.js server actions with the in-process client, guard mutations with action(), and drop in sign-in, step-up, and reset forms. Server actions are where Next.js lets you change cookies, so they are where people sign in, sign out, step up, and mutate data. `@better-iam/next` gives you three layers: * `iamNext.client()`: the full typed API, bound to the current request and running in process. * `iamNext.action(fn, spec)`: a guard that turns IAM failures into a serializable result for `useActionState`. * `iamNext.authActions()` and the forms in `@better-iam/next/client`: complete authentication flows that work with JavaScript disabled. ## The in-process client [#the-in-process-client] Why not call `iam.api.auth.signIn` directly from the action? It would verify the password and return a session token, but nothing would reach the browser. The session cookie, its attributes (host prefix, `HttpOnly`, `SameSite`, lifetime), and the remembered-device cookie are set by the IAM HTTP handler. `iamNext.client()` runs each call through that handler, in process, and copies what it sets onto your response. `iamNext.client()` returns `createIamClient` whose transport is `iam.handler` in the same process. For each call it: 1. copies the caller's `cookie`, `authorization`, `user-agent`, `accept-language`, and forwarding headers (`x-forwarded-for`, `x-forwarded-host`, `x-forwarded-proto`, `x-real-ip`); 2. sets `Origin` to the IAM origin (`iam.endpoint.origin`, from `baseURL`) and sends JSON with `X-Better-IAM: 1`, which satisfies the server's CSRF boundary; 3. writes every `Set-Cookie` the handler returns (session, trusted device, sign-out clearing) through `cookies().set`. ```ts title="app/account/actions.ts" 'use server'; import { iamNext } from '@/lib/iam-next'; // Any IAM call from an action: cookies the server issues are written with cookies().set. export async function revokeOtherDevices() { await iamNext.client().auth.revokeOtherSessions(); } ``` This makes sign-in, MFA, sign-out, password change, and trusted-device flows work as `
` without client JavaScript. Rate limits, tenant authentication policies, session metadata (`clientInfo`), and audit events behave exactly as they do for browser calls. Next checks the `Origin` of the incoming server action POST itself, so the forwarded cookie cannot be replayed cross-site. Failures reject with an `IamClientError` carrying the server's `code`. > **Cookie writes need an action or a route handler.** Next only allows cookie writes in server actions and route handlers. A server component can call read-only methods, such as `iamNext.client().identities.list(...)`. A method that issues a cookie throws an error naming the cookie ("could not update the ... cookie; call this method from a server action or route handler"). `iamNext.clearSessionCookie()` expires the session cookie in the current response. Sign-out calls it when the server no longer knows the presented session, so a stale cookie does not linger. ## Guarded mutations [#guarded-mutations] `iamNext.action(fn, spec)` requires a session, optionally a [step-up](/docs/frameworks/nextjs/advanced#step-up), and optionally an action, then calls `fn(session, ...args)`. It returns an `ActionResult`: `{ ok: true, data }` or `{ ok: false, error: { code, message } }`, so IAM failures reach the form instead of an error boundary. **actions.ts:** ```ts title="app/projects/[id]/actions.ts" 'use server'; import { revalidatePath } from 'next/cache'; import { iamNext } from '@/lib/iam-next'; export const rename = iamNext.action( async (session, _previous: unknown, form: FormData) => { const id = String(form.get('id')); await db.projects.rename(id, String(form.get('name'))); revalidatePath(`/projects/${id}`); return { id }; }, { authorize: { action: 'projects:write', resource: ({ args: [, form] }) => ({ type: 'project', id: String(form.get('id')) }), }, }, ); ``` **form.tsx:** ```tsx title="app/projects/[id]/rename-form.tsx" 'use client'; import { useActionState } from 'react'; import { rename } from './actions'; export function RenameForm({ id }: { id: string }) { const [state, formAction, pending] = useActionState(rename, null); return ( {state?.ok === false && (

{state.error.code}: {state.error.message}

)}
); } ``` * **Codes you will see.** `UNAUTHENTICATED` (no session), `MFA_REQUIRED`, `RECENT_AUTH_REQUIRED`, or `IMPERSONATION_RESTRICTED` (step-up), `ACCESS_DENIED`, `RATE_LIMITED`, and validation codes. * **Your own failures.** Throw an `IamError` (from `@better-iam/core`, or `better-iam/core`) inside `fn`, for example `new IamError('INVALID_INPUT', 'Write something first', 400)`, to report it the same way. * **Everything else propagates.** Other errors, even with a `code` field, and `redirect()` or other Next control flow thrown by `fn` are rethrown unchanged. * **Argument order.** Pass the handler first and the spec second, so TypeScript infers the arguments from the handler's annotations and checks the `resource` callback against them. `authorize` callbacks receive `{ session, args }`. Server actions never redirect or interrupt on an IAM failure, and they need no `Origin` check of their own. ## Auth forms [#auth-forms] Signing in is rarely one request. A password may lead to an authenticator code, an emailed code, a recovery code, or first-time enrollment. A reset needs an email step and a link step, and an invitation may require MFA before the first session. Writing those flows as server actions means carrying the pending challenge between steps without leaking secrets, redirecting only to safe paths, and keeping every step usable without JavaScript. `iamNext.authActions(options)` returns server actions that do this, and `@better-iam/next/client` has a form for each. Export the actions from a `'use server'` module and pass them to the forms. ### Export the actions [#export-the-actions] ```ts title="app/auth-actions.ts" 'use server'; import { iamNext } from '@/lib/iam-next'; const auth = iamNext.authActions({ afterSignIn: '/dashboard', discover: true }); export const signIn = auth.signIn; export const reauthenticate = auth.reauthenticate; export const requestPasswordReset = auth.requestPasswordReset; export const resetPassword = auth.resetPassword; export const signUp = auth.signUp; export const verifyEmail = auth.verifyEmail; export const acceptInvitation = auth.acceptInvitation; export async function signOut() { await auth.signOut(); } ``` Export each action as its own `const`: Next registers every export of a `'use server'` module as an action. `signOut` is wrapped because it takes no state. ### Render the forms [#render-the-forms] ```tsx title="app/login/page.tsx" import Link from 'next/link'; import { SignInForm } from '@better-iam/next/client'; import { signIn } from '../auth-actions'; export default async function Login(props: { searchParams: Promise<{ next?: string; org?: string; reset?: string }>; }) { const { next, org, reset } = await props.searchParams; return (

Sign in

{reset &&

Your password was changed. Sign in with the new one.

} Forgot your password?
); } ``` ### Add the other pages [#add-the-other-pages] **Forgot password:** ```tsx title="app/forgot/page.tsx" import { PasswordResetRequestForm } from '@better-iam/next/client'; import { requestPasswordReset } from '../auth-actions'; export default function Forgot() { return ; } ``` **Reset link:** ```tsx title="app/reset/page.tsx" import { PasswordResetForm } from '@better-iam/next/client'; import { resetPassword } from '../auth-actions'; // The page a password-reset email links to: /reset?tenantId=...&token=... export default async function Reset(props: { searchParams: Promise<{ tenantId?: string; token?: string }>; }) { const { tenantId, token } = await props.searchParams; if (!tenantId || !token) return

This reset link is incomplete.

; return ; } ``` **Step-up:** ```tsx title="app/reauth/page.tsx" import { ReauthenticateForm } from '@better-iam/next/client'; import { iamNext } from '@/lib/iam-next'; import { reauthenticate } from '../auth-actions'; // The page stepUp guards redirect to (stepUpPath), with ?next= and ?reason=. export default async function Reauthenticate(props: { searchParams: Promise<{ next?: string; reason?: string }>; }) { const { next } = await props.searchParams; await iamNext.requireSession({ returnTo: '/reauth' }); return ; } ``` **Sign out:** ```tsx title="app/sign-out-button.tsx" import { signOut } from './auth-actions'; // A plain form: works in server components and without client JavaScript. export function SignOutButton() { return (
); } ``` ### The actions [#the-actions] Each action moves through steps; the submit button the person presses sends an `intent` field that picks the next one. | Action | Steps and intents | Finishes with | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `signIn` | `password` or `send-code` then `code` (emailed sign-in code); then `mfa`, `email-code`, `recovery`, or `enroll` for the second factor; `cancel` starts over | A redirect to a safe `next` (or `afterSignIn`); after enrollment, the `done` step shows the recovery codes once | | `reauthenticate` | `password`, then the same second-factor intents | A redirect to `next`; the replaced session is ended | | `requestPasswordReset` | Email (and organization) | The `sent` step, with the same notice whether or not the account exists | | `resetPassword` | `tenantId` and `token` from the email link, the new password, an optional confirmation | A redirect to `loginPath?reset=1` | | `signUp` / `verifyEmail` | Self-registration (`authentication.signUpEnabled`) / the email link | `sent`, or a redirect to `loginPath?registered=1` / `?verified=1` | | `acceptInvitation` | `kind` `member` or `owner`, `tenantId`, `token`, name, password, then enrollment when the tenant requires MFA | A redirect to `next` | | `signOut` | None | Clears the cookie, also when the server no longer knows the session, and redirects to the login path | Every action except `signOut` takes `(previousState, formData)` and returns an `AuthFormState`: | Field | Meaning | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `step` | `credentials`, `mfa`, `enroll`, `code-sent`, `sent`, or `done` | | `error` | `{ code, message, field?, retryAfterMs? }`: the server's code, the offending form field, and the wait for `RATE_LIMITED` | | `mfa` | The pending challenge: `tenantId`, `challenge`, `enrollmentRequired`, `emailCodeAvailable`, `passkeyAvailable`, and while enrolling `enrollment: { secret, uri }` | | `recoveryCodes` | Shown once after enrolling an authenticator | | `notice` | A short status, for example "We sent a code to [ada@example.com](mailto:ada@example.com)" | | `values` | Non-secret inputs to refill the form: `email`, `org`, `tenantId`, `name` | | `next`, `keepSignedIn` | The safe return path and the "keep me signed in" choice, carried through later steps | Passwords, codes, session tokens, and session records never enter the state. The MFA challenge travels in hidden fields rather than server memory, which is what lets a form with JavaScript disabled post its second step. The server still validates every challenge, so a forged field only ever fails. Every redirect goes through `safeRedirectPath`. * **Organization.** The actions find the tenant from a `tenantId` field, an `org` slug (`tenants.lookup`), your `resolveTenant(form)`, or, with `discover: true`, the verified domain of the email (`domains.discover`). * **Keeping a session.** "Keep me signed in" (`keepSignedIn`) sends `X-Better-IAM-Persistent`, so the cookie lasts or ends with the browser. Step-up keeps a browser-session cookie as one unless the form opts in. "Remember this device" (`rememberDevice`) on the authenticator step returns a device cookie that later sign-ins present automatically. * **Error text.** `messages` overrides the text for any error code or notice. ### The forms [#the-forms] Each form renders the fields and steps of the action it is paired with, so a page only passes the action and a few props: | Form | Props besides `action` | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SignInForm` | `next`, `org`, `tenantId`, `email`, `showOrganization` (default true unless `tenantId` or `org` is given), `keepSignedIn` (offer the checkbox), `passwordless` (offer an emailed sign-in code) | | `ReauthenticateForm` | `next`, `keepSignedIn` (whether the new cookie outlives the browser session; off by default) | | `PasswordResetRequestForm` | `org`, `tenantId` | | `PasswordResetForm` | `tenantId`, `token` | | `SignUpForm` | `tenantId`, `next` | | `InvitationForm` | `tenantId`, `token`, `kind` (`member` or `owner`), `next` | Every form also takes `labels` (override any default English string) and `className`. The forms render unstyled semantic HTML with hooks for your CSS: `
` and `
`. Labels are bound, errors are tied to their field with `aria-invalid` and `aria-describedby` and announced with `role="alert"`, notices use `role="status"`, and the first field of each step takes focus. Alternative steps are named submit buttons (`name="intent"`), so a form works with JavaScript disabled, and `useActionState` keeps it in place when JavaScript is on. Each form has a `*View` twin (`SignInFormView`, `ReauthenticateFormView`, ...) that takes `{ state, formAction, pending }` instead of `action`, for your own container or for tests. > **Passkeys need the browser.** Passkey sign-in and passkey MFA run the WebAuthn ceremony in the browser, so the server actions cannot do them. Use [`better-iam/client/passkeys`](/docs/frameworks/client#passkeys) with the typed client for those. ## Next steps [#next-steps] - [Organizations in the URL](/docs/frameworks/nextjs/organizations): Send people to the right organization's sign-in with `?org=`. - [Step-up](/docs/frameworks/nextjs/advanced#step-up): The re-authentication page `ReauthenticateForm` belongs on. - [MFA](/docs/guides/authentication/mfa): Every second-factor path the sign-in form can take. # accessPaths (/docs/reference/api/access-paths) > Access paths tell a denied person what they can do on their own to be allowed, such as stepping up to MFA or requesting a package. Access paths tell a denied person what they can do on their own to be allowed, such as stepping up to MFA or requesting a package. When your application refuses an action, `accessPaths.find` lists those options, so the error page can offer a button instead of "contact your administrator". See the [access paths guide](/docs/guides/governance/access-paths). ## How paths are verified [#how-paths-are-verified] Each candidate path is applied inside a transaction that is always rolled back, and the ordinary authorization decision is run again. A path is listed only when that decision then allows the action, so the list never promises access that would still be refused: * **`mfa`**: step up to multi-factor authentication, when the session has no MFA and an MFA session would be allowed. * **`accept-agreements`**: accept the required [agreements](/docs/reference/api/agreements) the person still owes, listed with the versions to accept. * **`activate`**: activate one of the person's eligible [just-in-time bindings](/docs/guides/privileged-access/elevation), held directly or through a group, with the binding's activation settings (`requireApproval`, `requireJustification`, `requireMfa`, `maxActivationMs`). Listed only when the person holds `iam:bindings:activate` on the role. * **`request-package`**: request a requestable [access package](/docs/reference/api/packages#request). Listed only when the person holds `iam:packages:request` on the package. The request still needs an approver's decision. Each path is tested on its own, so when only a combination would help (MFA and an activation, for example) neither is listed. At most 50 eligible bindings and 50 requestable packages are tried. An empty list means nothing the person can do alone would help: they need an administrator. | Method | What it does | Access | | --------------- | -------------------------------------------------------------------------------------- | ---------- | | [`find`](#find) | Lists what you could do yourself to be allowed an action on a resource you are denied. | Credential | ## find [#find] Lists what you could do yourself to be allowed an action on a resource you are denied. **HTTP:** `POST /api/iam/accessPaths/find` (requires a credential) · **Browser client:** `client.accessPaths.find()` * **Permission:** None beyond your own ordinary session of the tenant (not an assumed role, another tenant's session, or impersonation). * **Audited as:** Not audited; nothing is saved. * **Errors:** `ACCESS_DENIED` from a role session or another tenant's session; `IMPERSONATION_RESTRICTED` from an impersonation session; `INVALID_ACTION` for an action the catalog does not know; `INVALID_INPUT` when the action or resource is missing. When you are already allowed, the result is `allowed: true` with the decision reason and no paths. When you are denied, `reason` is always `ACCESS_DENIED`: like `authorize`, the call does not reveal which statement refused you. Call it from the code path that handles a denial, with the same action and resource you just checked. `useAccessPaths` wraps it for React and Vue apps. ```ts const result = await iam.api.accessPaths.find(credential, { tenantId, action: 'documents:delete', resource: { type: 'document', id: 'doc_42' }, }); for (const path of result.paths) { if (path.kind === 'mfa') showStepUpButton(); if (path.kind === 'activate') showActivateButton(path.bindingId, path.role.name); if (path.kind === 'request-package') showRequestButton(path.package.id, path.requireJustification); if (path.kind === 'accept-agreements') showTermsDialog(path.agreements); } ``` ```ts title="Signature" iam.api.accessPaths.find( credential: CredentialInput, input: { tenantId: string; action: string; resource: { type: string; id: string } }, ): Promise ``` # accessRequests (/docs/reference/api/access-requests) > Access requests let members ask for specific roles instead of asking an administrator to bind them by hand. Access requests let members ask for specific roles instead of asking an administrator to bind them by hand. A member names up to 20 roles with a justification and an optional duration; a reviewer approves or denies, and approval creates the bindings under the reviewer's own grant authority, so a reviewer can never grant more than they could bind directly. ## Request lifecycle [#request-lifecycle] A request starts `pending` and ends in exactly one of `approved`, `denied`, `cancelled` (by the requester, or when the requester is offboarded or deleted), or `expired`. Two times matter: * **How long a request waits.** The request's `expiresAt` is when a pending request lapses: `accessRequests.lifetimeMs` after it was made (seven days by default; a [deployment option](/docs/operations/deployment/configuration) between one minute and 365 days). A lapsed request is reported as `expired` at once and marked so in storage by the purge worker (`iam.purgeDeleted()`). * **How long the access lasts.** `durationSeconds` (at least 60, at most `accessRequests.maxDurationSeconds`, 90 days by default) sets the end of the granted bindings, counted from approval. The request records it as `grantExpiresAt`. Without a duration the bindings are permanent. Approved bindings are ordinary role bindings tagged with `accessRequestId`. They end at `grantExpiresAt`, or when an administrator removes them with [`bindings.delete`](/docs/reference/api/bindings#delete); the request itself only records the decision. No notifications are sent: reviewers find work with `list({ status: 'pending' })`. Use access requests for ad hoc roles. For a curated bundle of roles and groups with designated approvers, use requestable [access packages](/docs/reference/api/packages#request) instead. | Method | What it does | Access | | ----------------------- | -------------------------------------------------------------------------------------------------------- | ---------- | | [`approve`](#approve) | Approves a pending request, binding each requested role to the requester under your own grant authority. | Credential | | [`cancel`](#cancel) | Withdraws one of your own pending requests. | Credential | | [`create`](#create) | Asks for one or more roles for yourself, optionally for a limited time. | Credential | | [`deny`](#deny) | Refuses a pending request, with an optional note for the requester. | Credential | | [`get`](#get) | Returns one request. | Credential | | [`list`](#list) | Lists the tenant's requests, newest first, optionally by status or requester. | Credential | | [`listMine`](#listmine) | Lists your own requests, newest first, optionally by status. | Credential | ## approve [#approve] Approves a pending request, binding each requested role to the requester under your own grant authority. **HTTP:** `POST /api/iam/accessRequests/approve` (requires a credential) · **Browser client:** `client.accessRequests.approve()` * **Permission:** `iam:access-requests:review` on the request, plus `iam:bindings:create` on each requested role and a grant authority, exactly as [`bindings.create`](/docs/reference/api/bindings#create) requires. * **Audited as:** `iam:access-requests:review`, plus `access-request:approve` with the requester, roles, binding IDs, and `grantExpiresAt`. * **Errors:** `INVALID_TRANSITION` (409) when the request is no longer pending, including when it has lapsed; `ACCESS_DENIED` when you are the requester or cannot bind one of the roles; `INVALID_IDENTITY` when the requester is not active; `NOT_FOUND` when the request, the requester, or a role is gone; `INVALID_INPUT` for a `durationSeconds` out of range; `GRANT_AUTHORITY_REQUIRED` without a grant authority; `SOD_CONFLICT` when the roles would create a [separation-of-duties](/docs/guides/authorization/separation-of-duties) conflict; `INVARIANT_VIOLATION` when they would break an enforced invariant. `durationSeconds` overrides the duration the requester asked for; the bindings end that long after approval. If the requester already holds one of the roles through a binding under your authority, that binding is reused and its end replaced by the approved one (removed, when no duration applies). The optional `note` is stored on the request. ```ts await iam.api.accessRequests.approve(reviewerCredential, { tenantId, requestId, durationSeconds: 8 * 60 * 60, // one working day instead of the week they asked for note: 'Approved for the incident review.', }); ``` ```ts title="Signature" iam.api.accessRequests.approve( credential: CredentialInput, input: { tenantId: string; requestId: string; durationSeconds?: number; note?: string; }, ): Promise ``` ## cancel [#cancel] Withdraws one of your own pending requests. **HTTP:** `POST /api/iam/accessRequests/cancel` (requires a credential) · **Browser client:** `client.accessRequests.cancel()` * **Permission:** `iam:access-requests:create` on the request, and you must be the requester. * **Audited as:** `iam:access-requests:create`. * **Errors:** `ACCESS_DENIED` when the request is someone else's; `INVALID_TRANSITION` when it is no longer pending; `NOT_FOUND` when it is not in this tenant. ```ts title="Signature" iam.api.accessRequests.cancel( credential: CredentialInput, input: { tenantId: string; requestId: string }, ): Promise<{ status: string; reviewedAt: number; requesterId: string; roleIds: string[]; justification?: string; durationSeconds?: number; createdAt: number; expiresAt: number; reviewerId?: string; note?: string; bindingIds?: string[]; grantExpiresAt?: number; id: string; tenantId: string; uniqueKey?: string; }> ``` ## create [#create] Asks for one or more roles for yourself, optionally for a limited time. **HTTP:** `POST /api/iam/accessRequests/create` (requires a credential) · **Browser client:** `client.accessRequests.create()` * **Permission:** `iam:access-requests:create` on the tenant, from an ordinary session of that tenant. * **Audited as:** `iam:access-requests:create`. * **Errors:** `INVALID_INPUT` from a role session or another tenant's session, for zero or more than 20 roles, or a `durationSeconds` out of range; `PROTECTED_RESOURCE` for an owner role; `NOT_FOUND` when a role is not in this tenant; `CONFLICT` when a pending request for the same set of roles exists; `TOO_MANY_REQUESTS` (429) when you already have 20 pending requests; `TENANT_INACTIVE` when the tenant is not active. Nothing is granted until a reviewer approves. `justification` (up to 2048 characters) is shown to reviewers. Grant `iam:access-requests:create` to every member, for example through a group everyone belongs to, and `iam:access-requests:review` to the people who decide. ```ts const request = await iam.api.accessRequests.create(memberCredential, { tenantId, roleIds: [supportAdminRole.id], justification: 'Covering the support rotation this week', durationSeconds: 7 * 24 * 60 * 60, }); // request.status === 'pending'; request.expiresAt is when it lapses if nobody decides ``` ```ts title="Signature" iam.api.accessRequests.create( credential: CredentialInput, input: { tenantId: string; roleIds: string[]; justification?: string; durationSeconds?: number; }, ): Promise ``` ## deny [#deny] Refuses a pending request, with an optional note for the requester. **HTTP:** `POST /api/iam/accessRequests/deny` (requires a credential) · **Browser client:** `client.accessRequests.deny()` * **Permission:** `iam:access-requests:review` on the request. * **Audited as:** `iam:access-requests:review`, plus `access-request:deny` with the requester and roles. * **Errors:** `INVALID_TRANSITION` when the request is no longer pending; `NOT_FOUND` when it is not in this tenant. ```ts title="Signature" iam.api.accessRequests.deny( credential: CredentialInput, input: { tenantId: string; requestId: string; note?: string }, ): Promise ``` ## get [#get] Returns one request. **HTTP:** `POST /api/iam/accessRequests/get` (requires a credential) · **Browser client:** `client.accessRequests.get()` * **Permission:** `iam:access-requests:read` on the request. * **Audited as:** `iam:access-requests:read`. * **Errors:** `NOT_FOUND` when the request is not in this tenant. A pending request past its lifetime is returned as `expired`. ```ts title="Signature" iam.api.accessRequests.get( credential: CredentialInput, input: { tenantId: string; requestId: string }, ): Promise ``` ## list [#list] Lists the tenant's requests, newest first, optionally by status or requester. **HTTP:** `POST /api/iam/accessRequests/list` (requires a credential) · **Browser client:** `client.accessRequests.list()` * **Permission:** `iam:access-requests:read` on the tenant. * **Audited as:** `iam:access-requests:read`. * **Errors:** `INVALID_INPUT` for an unknown `status`. The `status` filter matches the stored status, so until the purge worker runs, `status: 'pending'` can include lapsed requests, which are reported as `expired`. ```ts title="Signature" iam.api.accessRequests.list( credential: CredentialInput, input: { tenantId: string; status?: AccessRequestStatus; requesterId?: string }, ): Promise ``` ## listMine [#listmine] Lists your own requests, newest first, optionally by status. **HTTP:** `POST /api/iam/accessRequests/listMine` (requires a credential) · **Browser client:** `client.accessRequests.listMine()` * **Permission:** `iam:access-requests:create` on the tenant, so anyone who may ask can see their own requests. * **Audited as:** `iam:access-requests:create`. * **Errors:** `INVALID_INPUT` for an unknown `status`. ```ts title="Signature" iam.api.accessRequests.listMine( credential: CredentialInput, input: { tenantId: string; status?: AccessRequestStatus }, ): Promise ``` # actions (/docs/reference/api/actions) > Actions are the names that roles and policies allow or deny, such as iam:groups:update or documents:write. Actions are the names that roles and policies allow or deny, such as `iam:groups:update` or `documents:write`. The [permission catalog](/docs/guides/authorization/catalog) holds every action that exists: the built-in `iam:*` actions, the actions your configuration and plugins declare, and, when the deployment sets `permissions.mode: 'tenant-defined'`, actions a tenant registers itself. Stored policies may only name actions in the catalog, so a typo fails with `INVALID_ACTION` instead of silently granting nothing. This group lists the catalog and manages the tenant's own entries. ## Tenant-defined actions [#tenant-defined-actions] A tenant action is always namespaced under one of the tenant's own [resource types](/docs/reference/api/resource-types) as `{type}:{verb}`, for example `contract:approve` under a `contract` type. Register the type first; `resourceTypes.register` can create its actions in the same call. Registering an action grants nothing: it only makes the name available to roles and policies. Platform actions cannot be registered, renamed, or removed through this group. | Method | What it does | Access | | --------------------------- | ------------------------------------------------------------------------------------------------- | ---------- | | [`list`](#list) | Lists every action the tenant can use in policies: platform actions first, then the tenant's own. | Credential | | [`register`](#register) | Adds a `{type}:{verb}` action under one of the tenant's resource types. | Credential | | [`unregister`](#unregister) | Removes a tenant-defined action from the catalog. | Credential | ## list [#list] Lists every action the tenant can use in policies: platform actions first, then the tenant's own. **HTTP:** `POST /api/iam/actions/list` (requires a credential) · **Browser client:** `client.actions.list()` * **Permission:** `iam:actions:read` on the tenant. * **Audited as:** `iam:actions:read`. Each entry has a `name`, a `source` of `platform` or `tenant`, and the `resourceType` it belongs to when it was declared under one. Tenant actions also carry their `description`. Use it to populate policy and role editors. ```ts title="Signature" iam.api.actions.list( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## register [#register] Adds a `{type}:{verb}` action under one of the tenant's resource types. **HTTP:** `POST /api/iam/actions/register` (requires a credential) · **Browser client:** `client.actions.register()` * **Permission:** `iam:actions:create` on the tenant. * **Audited as:** `iam:actions:create`. * **Errors:** `CATALOG_LOCKED` (403) when the deployment does not allow tenant-defined actions; `INVALID_ACTION` when the name is not `{type}:{verb}`, collides with a platform action or namespace, or its type is not a tenant-defined resource type; `CONFLICT` when the action already exists. The verb starts with a letter and uses letters, digits, `_`, or `-`. The description is optional, at most 512 characters. ```ts await iam.api.actions.register(credential, { tenantId, name: 'contract:countersign', description: 'Countersign a contract after legal review', }); ``` ```ts title="Signature" iam.api.actions.register( credential: CredentialInput, input: { tenantId: string; name: string; description?: string }, ): Promise ``` ## unregister [#unregister] Removes a tenant-defined action from the catalog. **HTTP:** `POST /api/iam/actions/unregister` (requires a credential) · **Browser client:** `client.actions.unregister()` * **Permission:** `iam:actions:delete` on the tenant. * **Audited as:** `iam:actions:delete`. * **Errors:** `NOT_FOUND` when the tenant has no action by that name (platform actions included); `RESOURCE_IN_USE` while a stored policy or inline role document names the action exactly. Remove the action from every policy and role first; the check exists so no stored document is left naming an action that no longer exists. Wildcard patterns such as `contract:*` do not count as references. ```ts title="Signature" iam.api.actions.unregister( credential: CredentialInput, input: { tenantId: string; name: string }, ): Promise<{ deleted: boolean }> ``` # agents (/docs/reference/api/agents) > An agent is an AI agent registered as an account of its own: an identity of kind agent that a person answers for. An agent is an AI agent registered as an account of its own: an identity of kind `agent` that a person answers for. Like a service account it holds API keys ([`credentials.create`](/docs/reference/api/credentials#create)) and never signs in, and roles, groups, and policies apply to it as to anyone. Two things set it apart: its credentials work only while its sponsor is an active person of the same tenant, and its `boundary` policy caps everything it does, whatever its roles say and whoever it acts for. People let an agent act on their behalf with [delegations](/docs/reference/api/delegations). The repository guide is `docs/agents.md`. ## Sponsors and standing [#sponsors-and-standing] Every agent has a sponsor (`agent.sponsorId`): an active, unexpired person of the agent's tenant, accountable for it. `create` makes the caller the sponsor when the caller is a person in their own session of the tenant; otherwise name one with `sponsorId`. An agent's **standing** says whether it may act right now: * `ok`: it may act. * `suspended`: the agent is disabled, as `suspend` does. * `expired`: its `expiresAt` has passed. * `deleted`: it was deleted. * `sponsor-missing`: no person of the tenant matches `sponsorId`. * `sponsor-inactive`: the sponsor is disabled, deleted, or past their own `expiresAt`. Anything but `ok` refuses every credential of the agent with `UNAUTHENTICATED`: its API keys, its session tokens, and the delegated sessions in which it acts for people. `credentials.create` refuses new keys meanwhile (`INVALID_IDENTITY`). Offboarding a sponsor with a successor ([`identities.offboard`](/docs/reference/api/identities#offboard)) hands their agents to the successor, audited as `agent:sponsor-change`; without a successor the agents stay with the leaver and are refused until an administrator names a new sponsor with `update`. ## Profile and ceiling [#profile-and-ceiling] The profile fields `create` and `update` accept (in `update`, `null` clears an optional one): | Field | Meaning | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `model`, `provider` | What the agent runs on. The model is 1 to 128 letters, digits, or `._:/@+-`; the provider 1 to 64 letters, digits, or `._-`, stored in lowercase. | | `purpose`, `url` | Shown to people deciding whether to delegate to the agent: up to 1024 characters, and an http(s) URL. | | `protocols` | An informational list such as `mcp` and `a2a`: at most 16 short lowercase names, deduplicated and sorted. | | `boundary` | A policy document that caps everything the agent does, with its own keys and in delegated sessions. Changes apply to live sessions at once. | | `delegable` | `false` refuses new delegations and the use of existing ones until it is turned back on. | | `maxDelegatedSessionSeconds` | The longest delegated session the agent may open, 60 to 43200 seconds (3600 when unset). | | `tokenAudiences` | Services outside Better IAM the agent may present a person's delegation to with a [delegation token](/docs/reference/api/delegations#issuetoken): at most 16 http(s) URLs or other absolute URIs (`urn:example:api`), without user info, query, or fragment. In a URL, `*` may start the host (`https://*.example.com`, any subdomain) or appear in the path (`https://api.example.com/v1/*`); a URL without a path matches only the root. Without it the agent gets no delegation tokens. | Decisions for an agent's own key, and for sessions in which it acts for someone, carry `principal.agentId`, `principal.agentSponsorId`, and, when set, `principal.agentModel` and `principal.agentProvider`; `principal.kind` is `agent` for the agent's own key. See [principal keys](/docs/guides/authorization/conditions#principal-keys). ## Who may call what [#who-may-call-what] Administrators use `iam:agents:create`, `iam:agents:read`, `iam:agents:update`, and `iam:agents:delete`, checked on `iam/{agentId}` (on the tenant for `create` and `list`). A sponsor manages their own agents from their own signed-in session of the tenant without any permission: `listMine`, `get`, `standing`, `activity`, `signCard`, `suspend`, and `resume` of a suspension they made themselves. An agent may call `signCard` for itself with its own unscoped key. When the caller is the agent's sponsor, these sponsor rules apply even if they also hold the administrative permission. Every person of the tenant may browse delegable agents with `catalog`. A "person's own session" excludes API keys, role sessions, session tokens, delegated sessions, and impersonation. | Method | What it does | Access | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`activity`](#activity) | Returns what an agent did, newest first: the audit events of its own credentials and of the sessions in which it acted for people. | Credential | | [`catalog`](#catalog) | Lists the agents people of the tenant may delegate to, with what a person needs to decide. | Credential | | [`create`](#create) | Registers an AI agent with a sponsor and an optional profile. | Credential | | [`delete`](#delete) | Deletes an agent, ending its keys and sessions and revoking every delegation to it. | Credential | | [`directory`](#directory) | Lists the tenant's agents that hold a current attested A2A card, for finding an agent to work with or hand work to. | Credential | | [`get`](#get) | Returns one agent with its standing, its live API keys, and counts of its delegations. | Credential | | [`list`](#list) | Lists the tenant's agents, newest first, optionally only one sponsor's or those in one standing. | Credential | | [`listMine`](#listmine) | Returns the agents you sponsor, newest first, with the same detail as `get`. | Credential | | [`resume`](#resume) | Lifts an agent's suspension, so its kept API keys work again. | Credential | | [`signCard`](#signcard) | Signs an agent's A2A (Agent2Agent) agent card, so other agents can check that it is a registered agent in good standing of your organization. | Credential | | [`standing`](#standing) | Tells whether an agent may act right now and, if not, why. | Credential | | [`suspend`](#suspend) | Stops an agent at once (the kill switch) and keeps its API keys for `resume`. | Credential | | [`suspendAll`](#suspendall) | The organization-wide emergency stop: suspends every active agent of the tenant at once. | Credential | | [`update`](#update) | Changes an agent's name, description, expiry, attributes, profile, or sponsor. | Credential | ## activity [#activity] Returns what an agent did, newest first: the audit events of its own credentials and of the sessions in which it acted for people. **HTTP:** `POST /api/iam/agents/activity` (requires a credential) · **Browser client:** `client.agents.activity()` * **Permission:** `iam:agents:read` on the agent, or none for its sponsor in their own session. * **Audited as:** `iam:agents:read` for administrators; not audited for the sponsor. * **Errors:** `NOT_FOUND` when the id is not an agent of this tenant; `INVALID_INPUT` for a `limit` outside 1 to 500 or a malformed `offset`, `from`, or `to`. An event belongs to the agent when the agent is its actor (its API keys and session tokens) or when its `sessionContext.agentId` names the agent (a delegated session, where the actor is the person it acted for). Both allowed and denied events are included, so a sponsor sees what the agent tried as well as what it did. Page with `limit` (100 by default) and `offset`, and bound the time with `from` and `to` (epoch milliseconds). ```ts const recent = await iam.api.agents.activity(sponsorSession, { tenantId, agentId, limit: 50 }); const refused = recent.filter((event) => event.outcome === 'deny'); ``` ```ts title="Signature" iam.api.agents.activity( credential: CredentialInput, input: ActivityQuery & { tenantId: string; agentId: string }, ): Promise ``` ## catalog [#catalog] Lists the agents people of the tenant may delegate to, with what a person needs to decide. **HTTP:** `POST /api/iam/agents/catalog` (requires a credential) · **Browser client:** `client.agents.catalog()` * **Permission:** None beyond a person's own session of the tenant. * **Audited as:** Not audited; it only reads. * **Errors:** `ACCESS_DENIED` for any other credential, including a session of another tenant. Only active, delegable agents in good standing (`ok`) are listed, sorted by name. Each entry has `id` and `name`, the `description`, `purpose`, `model`, `provider`, `url`, `protocols`, and `tokenAudiences` when set, and `sponsorName`, so a consent screen can show who answers for the agent and which outside services it may carry a delegation to. Keys, the boundary, and sponsor ids are not included. Use it for a "connect an agent" page that ends in [`delegations.grant`](/docs/reference/api/delegations#grant). ```ts title="Signature" iam.api.agents.catalog( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## create [#create] Registers an AI agent with a sponsor and an optional profile. **HTTP:** `POST /api/iam/agents/create` (requires a credential) · **Browser client:** `client.agents.create()` * **Permission:** `iam:agents:create` on the tenant. * **Audited as:** `iam:agents:create`, plus `agent:create` with the sponsor, model, and provider. * **Errors:** `INVALID_SPONSOR` when `sponsorId` is not an active, unexpired person of the tenant, or when it is left out and the caller is not a person in their own session (an API key, for example); `LIMIT_EXCEEDED` (409) past the tenant's `agents` limit; `INVALID_INPUT` for a malformed profile field, a `description` over 512 characters, or an `expiresAt` that is not in the next ten years; `INVALID_POLICY` or `INVALID_ACTION` for a malformed `boundary`. The agent starts `active` with no keys: issue them with `credentials.create` as for a service account, and give it access with roles and bindings. `expiresAt` (epoch milliseconds) schedules its deactivation. Deleted agents do not count toward the limit. The result carries the agent's `standing` and a `sponsor` summary (id, name, email, status). ```ts const agent = await iam.api.agents.create(credential, { tenantId, name: 'Support triage', purpose: 'Labels and routes incoming support tickets', model: 'claude-sonnet-5', provider: 'anthropic', protocols: ['mcp'], sponsorId: aliceId, boundary: { version: 1, statements: [{ effect: 'allow', actions: ['tickets:*'], resources: ['ticket/*'] }], }, }); const { token } = await iam.api.credentials.create(credential, { tenantId, identityId: agent.id, name: 'production', }); ``` ```ts title="Signature" iam.api.agents.create( credential: CredentialInput, input: CreateAgentInput, ): Promise ``` ## delete [#delete] Deletes an agent, ending its keys and sessions and revoking every delegation to it. **HTTP:** `POST /api/iam/agents/delete` (requires a credential) · **Browser client:** `client.agents.delete()` * **Permission:** `iam:agents:delete` on the agent, and a recent sign-in. * **Audited as:** `iam:agents:delete`, plus `identity:delete` with `kind: 'agent'` and `delegationsRevoked`. * **Errors:** `RECENT_AUTH_REQUIRED` without a recent sign-in (temporary credentials never have one); `NOT_FOUND` when the id is not an agent of this tenant; `CONFLICT` when it is already deleted. Pending requests and active delegations involving the agent become `revoked`, and their delegated sessions end at once. Like any deleted identity, the agent leaves a tombstone (status `deleted`) so audit records keep resolving it, and its bindings, group memberships, and relationships are removed. The result is the tombstone. To stop an agent for a while and keep its keys, use `suspend`. ```ts title="Signature" iam.api.agents.delete( credential: CredentialInput, input: { tenantId: string; agentId: string }, ): Promise ``` ## directory [#directory] Lists the tenant's agents that hold a current attested A2A card, for finding an agent to work with or hand work to. **HTTP:** `POST /api/iam/agents/directory` (requires a credential) · **Browser client:** `client.agents.directory()` * **Permission:** None beyond a credential of the tenant: a person's session, an agent's or service account's key, or a delegated session. * **Audited as:** Not audited; it only reads. * **Errors:** `ACCESS_DENIED` for a credential of another tenant. Every [`signCard`](#signcard) stores the agent's latest signed card as its directory entry. Entries are listed while their attestation is valid and the agent is in good standing, sorted by name: `{ agentId, name, card, attestation, expiresAt }`, where `card` is the signed card itself, ready for `verifyAgentCard` from `@better-iam/a2a`. `skill` keeps agents whose card offers a skill with that id or tag, and `protocol` agents whose attestation lists that protocol. Suspending or deleting an agent removes it; an expired entry is swept away. ```ts const [translator] = await iam.api.agents.directory(credential, { tenantId, skill: 'translate' }); ``` ```ts title="Signature" iam.api.agents.directory( credential: CredentialInput, input: { tenantId: string; skill?: string; protocol?: string }, ): Promise ``` ## get [#get] Returns one agent with its standing, its live API keys, and counts of its delegations. **HTTP:** `POST /api/iam/agents/get` (requires a credential) · **Browser client:** `client.agents.get()` * **Permission:** `iam:agents:read` on the agent, or none for the agent's sponsor in their own session. * **Audited as:** `iam:agents:read` for administrators; not audited for the sponsor. * **Errors:** `NOT_FOUND` when the id is not an agent of this tenant. `keys` lists unexpired API keys by label only (id, name, creation and expiry times, and `lastUsedAt` once used), never token material. `delegations` counts the `active` and `pending` ones that have not lapsed, and `liveDelegatedSessions` the unexpired delegated sessions. A deleted agent is still returned, with standing `deleted`. ```ts title="Signature" iam.api.agents.get( credential: CredentialInput, input: { tenantId: string; agentId: string }, ): Promise ``` ## list [#list] Lists the tenant's agents, newest first, optionally only one sponsor's or those in one standing. **HTTP:** `POST /api/iam/agents/list` (requires a credential) · **Browser client:** `client.agents.list()` * **Permission:** `iam:agents:read` on the tenant. * **Audited as:** `iam:agents:read`. Deleted agents are left out unless `includeDeleted: true`. `standing: 'sponsor-inactive'` finds the agents that stopped because their sponsor left, the ones to hand to someone else with `update`. ```ts title="Signature" iam.api.agents.list( credential: CredentialInput, input: { tenantId: string; sponsorId?: string; standing?: AgentSummary['standing']; includeDeleted?: boolean; }, ): Promise ``` ## listMine [#listmine] Returns the agents you sponsor, newest first, with the same detail as `get`. **HTTP:** `POST /api/iam/agents/listMine` (requires a credential) · **Browser client:** `client.agents.listMine()` * **Permission:** None beyond a person's own session of the tenant. * **Audited as:** Not audited; it only reads. * **Errors:** `ACCESS_DENIED` for any other credential, including a session of another tenant. Deleted agents are left out. It backs a "my agents" page where sponsors see keys and delegations and reach the kill switch. ```ts title="Signature" iam.api.agents.listMine( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## resume [#resume] Lifts an agent's suspension, so its kept API keys work again. **HTTP:** `POST /api/iam/agents/resume` (requires a credential) · **Browser client:** `client.agents.resume()` * **Permission:** `iam:agents:update` on the agent and a recent sign-in, or none for the sponsor when they made the suspension themselves. * **Audited as:** `agent:resume`; an administrator's call also as `iam:agents:update`. * **Errors:** `ACCESS_DENIED` when the sponsor tries to lift someone else's suspension; `RECENT_AUTH_REQUIRED` for an administrator without a recent sign-in; `CONFLICT` (409) when the agent is not suspended; `INVALID_TRANSITION` (409) when it has expired (extend or clear `expiresAt` with `update` first); `NOT_FOUND` for a deleted agent. The agent returns to `active` with its keys, roles, and delegations as they were. Delegated sessions and session tokens ended by the suspension do not come back; the agent opens new ones. Its standing stays other than `ok` while its sponsor is inactive. ```ts title="Signature" iam.api.agents.resume( credential: CredentialInput, input: { tenantId: string; agentId: string }, ): Promise ``` ## signCard [#signcard] Signs an agent's A2A (Agent2Agent) agent card, so other agents can check that it is a registered agent in good standing of your organization. **HTTP:** `POST /api/iam/agents/signCard` (requires a credential) · **Browser client:** `client.agents.signCard()` * **Permission:** None for the agent itself with its own unscoped API key (not a key with `scopes`, and not a session token, role or delegated session); none for its sponsor in their own session; otherwise `iam:agents:update` on the agent. * **Audited as:** `agent:card-sign`, with the card's `url` and the attestation's expiry; an administrator's call also as `iam:agents:update`. * **Errors:** `FEATURE_DISABLED` (403) without the `a2a` option; `INVALID_INPUT` when the agent has no registered `url`, when the card's `url`, an `additionalInterfaces[].url` or a `supportedInterfaces[].url` is not on that URL's origin, or when the card is not a JSON object with a `name` of 1 to 256 characters (or is larger than 64 KiB); `INVALID_IDENTITY` (409) when the agent is not in good standing; `NOT_FOUND` when the id is not an agent of this tenant; `ACCESS_DENIED` for anyone else, including another agent's key or a scoped key of the agent itself. Pass the card as `card`. IAM drops any `signatures` and any attestation already in the card and sets `provider` to the tenant's name at the agent's registered origin, whatever the card said. It then adds the extension `urn:better-iam:a2a:attestation:v1` to `capabilities.extensions`. Its params are `issuer`, `tenantId`, `organization`, `agentId`, `agentName`, `sponsored: true`, `delegable`, `model`, `provider`, `protocols`, and `issuedAt`/`expiresAt` (ISO 8601, `a2a.cardLifetimeSeconds` apart, one hour by default). IAM signs the RFC 8785 canonical form of the card (without `signatures`) as a detached JWS: `alg` EdDSA or ES256, the key's `kid`, `typ` `JOSE`, and `jku` when `a2a.jwksUrl` is set. The result is `{ card, attestation, expiresAt }`. `card` carries one entry in `signatures`, and `expiresAt` is in epoch milliseconds. Verifiers check the card against the deployment's public card keys (`iam.a2a.jwks()`, served at `a2a.jwksUrl`) with `verifyAgentCard` or `discoverAgent` from `@better-iam/a2a`. Suspending, deleting, or un-sponsoring the agent stops new signatures at once, and signed cards stop verifying when their attestation expires. An agent's A2A server usually re-signs its own card with `createCardAttestor`. ```ts const { card } = await iam.api.agents.signCard( { token: agentKey }, { tenantId, agentId, card: { name: 'Triage', url: 'https://triage.acme.test/a2a', skills: [] } }, ); ``` ```ts title="Signature" iam.api.agents.signCard( credential: CredentialInput, input: { tenantId: string; agentId: string; card: Record }, ): Promise ``` ## standing [#standing] Tells whether an agent may act right now and, if not, why. **HTTP:** `POST /api/iam/agents/standing` (requires a credential) · **Browser client:** `client.agents.standing()` * **Permission:** `iam:agents:read` on the agent, or none for its sponsor in their own session. * **Audited as:** `iam:agents:read` for administrators; not audited for the sponsor. * **Errors:** `NOT_FOUND` when the id is not an agent of this tenant. The result is `{ agentId, standing }` with one of the values under [Sponsors and standing](#sponsors-and-standing), a lighter call than `get` when only the answer matters. ```ts title="Signature" iam.api.agents.standing( credential: CredentialInput, input: { tenantId: string; agentId: string }, ): Promise<{ agentId: string; standing: AgentStanding }> ``` ## suspend [#suspend] Stops an agent at once (the kill switch) and keeps its API keys for `resume`. **HTTP:** `POST /api/iam/agents/suspend` (requires a credential) · **Browser client:** `client.agents.suspend()` * **Permission:** `iam:agents:update` on the agent, or none for its sponsor in their own session. * **Audited as:** `agent:suspend`, with the `reason` and how many delegated sessions and session tokens ended; an administrator's call also as `iam:agents:update`. * **Errors:** `CONFLICT` (409) when it is already suspended; `NOT_FOUND` for a deleted agent or an id that is not an agent of this tenant; `ACCESS_DENIED` for anyone else. The agent becomes `disabled`, and `agent.suspended` records who suspended it, when, and why (`reason`, up to 512 characters). Its API keys are refused while it is suspended, and its live delegated sessions and session tokens are deleted now, so an agent working for people stops mid-task. Delegations stay in place for after `resume`. No recent sign-in is needed, so a sponsor can react immediately. ```ts await iam.api.agents.suspend(aliceSession, { tenantId, agentId, reason: 'Looping on the wiki' }); ``` ```ts title="Signature" iam.api.agents.suspend( credential: CredentialInput, input: { tenantId: string; agentId: string; reason?: string }, ): Promise ``` ## suspendAll [#suspendall] The organization-wide emergency stop: suspends every active agent of the tenant at once. **HTTP:** `POST /api/iam/agents/suspendAll` (requires a credential) · **Browser client:** `client.agents.suspendAll()` * **Permission:** `iam:agents:update` on the tenant; no recent sign-in, so it works during an incident. * **Audited as:** `agent:suspend-all` with the `reason`, the filters, and how many agents stopped; `agent:suspend` for each agent; and `iam:agents:update`. * **Errors:** `INVALID_INPUT` without a `reason` (up to 512 characters); `ACCESS_DENIED` without the permission. Each active agent is suspended exactly as [`suspend`](#suspend) does: its credentials are refused and its live delegated sessions and session tokens end now. `sponsorId`, `provider`, and `model` narrow the stop to one sponsor's agents or those running on one provider or model. Agents already suspended are left as they are. The result is `{ suspended, agentIds }`; agents come back one at a time with [`resume`](#resume). ```ts await iam.api.agents.suspendAll(admin, { tenantId, reason: 'Prompt injection incident' }); ``` ```ts title="Signature" iam.api.agents.suspendAll( credential: CredentialInput, input: { tenantId: string; reason: string; sponsorId?: string; provider?: string; model?: string; }, ): Promise<{ suspended: number; agentIds: string[] }> ``` ## update [#update] Changes an agent's name, description, expiry, attributes, profile, or sponsor. **HTTP:** `POST /api/iam/agents/update` (requires a credential) · **Browser client:** `client.agents.update()` * **Permission:** `iam:agents:update` on the agent. * **Audited as:** `iam:agents:update`, plus `agent:sponsor-change` (with `from` and `to`) when the sponsor changes. * **Errors:** `INVALID_SPONSOR` when the new `sponsorId` is not an active person of the tenant; `NOT_FOUND` for a deleted agent or an id that is not an agent of this tenant; `INVALID_INPUT`, `INVALID_POLICY`, or `INVALID_ACTION` as for `create`. Fields you leave out keep their values; `null` clears `description`, `expiresAt`, and the optional profile fields, and `attributes` replaces the whole set. A new `boundary` and `delegable: false` apply to live sessions at once, because both are read on every decision. Naming a new sponsor is how you bring back an agent whose sponsor left. ```ts // The sponsor left without a successor: hand the agent to someone else. await iam.api.agents.update(credential, { tenantId, agentId, sponsorId: bobId }); ``` ```ts title="Signature" iam.api.agents.update( credential: CredentialInput, input: UpdateAgentInput, ): Promise ``` # agreements (/docs/reference/api/agreements) > Agreements are versioned terms of use that a tenant asks its members to accept: an acceptable-use policy, an NDA, data-handling rules. Agreements are versioned terms of use that a tenant asks its members to accept: an acceptable-use policy, an NDA, data-handling rules. Better IAM records who accepted which version and when, and exposes the result to policies, so you can hold back access until people accept. See the [terms of use guide](/docs/guides/governance/agreements). ## Versions, lapses, and enforcement [#versions-lapses-and-enforcement] An agreement starts at version 1. Editing it with `newVersion: true` publishes the next version, and everyone must accept again; an edit without it (a typo fix, a new link) keeps existing acceptances valid. An acceptance counts only while it is for the current version and, when the agreement sets `reacceptAfterDays`, is younger than that many days (annual re-acceptance, for example). Each person has one acceptance record per agreement, replaced each time they accept. Enforcement is an ordinary policy decision. Every evaluation for a person in their own tenant carries two [condition](/docs/guides/authorization/conditions) keys: * `principal.agreements`: the names of the agreements the person has accepted in their current version. * `principal.pendingAgreements`: how many `required` agreements they still owe. Service accounts cannot accept anything, so nothing is pending for them. A deny statement on the count holds back access until every required agreement is accepted: ```json { "effect": "deny", "actions": ["documents:*"], "resources": ["*"], "conditions": { "NumericGreaterThan": { "principal.pendingAgreements": 0 } } } ``` `{ "ArrayContains": { "principal.agreements": ["Beta program"] } }` grants something only to people who accepted an optional agreement. Sessions of an assumed role carry neither key, so conditions on them do not match there. [`accessPaths.find`](/docs/reference/api/access-paths#find) tells a denied person when accepting their pending agreements would let them in. | Method | What it does | Access | | ----------------------- | ------------------------------------------------------------------------------------------------------ | ---------- | | [`accept`](#accept) | Records that you accept the given version of an agreement. | Credential | | [`create`](#create) | Publishes a new agreement at version 1, required by default. | Credential | | [`delete`](#delete) | Deletes an agreement together with every acceptance of it. | Credential | | [`list`](#list) | Lists the tenant's agreements by name, with their full text, version, and settings. | Credential | | [`listMine`](#listmine) | Returns every agreement of the tenant with the text and whether you have accepted its current version. | Credential | | [`status`](#status) | Reports who accepted an agreement's current version and which active people still owe it. | Credential | | [`update`](#update) | Edits an agreement, optionally publishing the change as a new version that everyone must accept again. | Credential | ## accept [#accept] Records that you accept the given version of an agreement. **HTTP:** `POST /api/iam/agreements/accept` (requires a credential) · **Browser client:** `client.agreements.accept()` * **Permission:** None beyond an ordinary session of the agreement's tenant. * **Audited as:** `agreement:accept`, with the agreement's name and version. * **Errors:** `VERSION_CONFLICT` (409) when `version` is not the current version; `IMPERSONATION_RESTRICTED` from an impersonation session; `INVALID_INPUT` for a service account; `ACCESS_DENIED` from a role session or another tenant's session; `NOT_FOUND` when the agreement is not in this tenant. Pass the `version` you showed the person, from `listMine`. If the agreement changed in the meantime the call fails, so nobody accepts text they were not shown. Accepting again restarts the `reacceptAfterDays` clock. The acceptance applies from the next authorization check; enforced invariants do not guard it. ```ts const mine = await iam.api.agreements.listMine(credential, { tenantId }); const owed = mine.filter((agreement) => agreement.required && !agreement.accepted); for (const agreement of owed) await iam.api.agreements.accept(credential, { tenantId, agreementId: agreement.id, version: agreement.version }); ``` ```ts title="Signature" iam.api.agreements.accept( credential: CredentialInput, input: { tenantId: string; agreementId: string; version: number }, ): Promise<{ accepted: boolean; version: number; acceptedAt: number }> ``` ## create [#create] Publishes a new agreement at version 1, required by default. **HTTP:** `POST /api/iam/agreements/create` (requires a credential) · **Browser client:** `client.agreements.create()` * **Permission:** `iam:agreements:manage` on the tenant. * **Audited as:** `iam:agreements:manage`. * **Errors:** `CONFLICT` when an agreement with the same name (ignoring case) exists; `LIMIT_EXCEEDED` (409) when the tenant already has 50; `INVALID_INPUT` for an empty name or one over 100 characters, empty content or content over 50 000 characters or with control characters other than tabs and line breaks, a `url` that is not http(s), or a `reacceptAfterDays` outside 1 to 3650; `INVARIANT_VIOLATION` when a new required agreement would make a policy deny someone an enforced invariant says must be allowed. `content` is the text people accept (plain text or Markdown); `url` optionally links to the canonical document. `required: false` makes it optional: it never counts toward `principal.pendingAgreements`, and people who accept it appear in `principal.agreements`. Publishing a required agreement raises every person's `principal.pendingAgreements` at once: if a policy already denies on that count, people lose the access it covers until they accept. ```ts await iam.api.agreements.create(credential, { tenantId, name: 'Acceptable use', content: 'Use company systems for work. Report incidents within 24 hours.', url: 'https://intranet.example.com/policies/acceptable-use', reacceptAfterDays: 365, }); ``` ```ts title="Signature" iam.api.agreements.create( credential: CredentialInput, input: AgreementInput & { tenantId: string }, ): Promise ``` ## delete [#delete] Deletes an agreement together with every acceptance of it. **HTTP:** `POST /api/iam/agreements/delete` (requires a credential) · **Browser client:** `client.agreements.delete()` * **Permission:** `iam:agreements:manage` on the agreement. * **Audited as:** `iam:agreements:manage`. * **Errors:** `NOT_FOUND` when the agreement is not in this tenant; `INVARIANT_VIOLATION` when the change would break an enforced invariant. Its name disappears from `principal.agreements` and, if it was required, it stops counting toward `principal.pendingAgreements`. The acceptance history is gone with it; the audit log keeps the `agreement:accept` events. ```ts title="Signature" iam.api.agreements.delete( credential: CredentialInput, input: { tenantId: string; agreementId: string }, ): Promise<{ deleted: boolean }> ``` ## list [#list] Lists the tenant's agreements by name, with their full text, version, and settings. **HTTP:** `POST /api/iam/agreements/list` (requires a credential) · **Browser client:** `client.agreements.list()` * **Permission:** `iam:agreements:read` on the tenant. * **Audited as:** `iam:agreements:read`. ```ts title="Signature" iam.api.agreements.list( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listMine [#listmine] Returns every agreement of the tenant with the text and whether you have accepted its current version. **HTTP:** `POST /api/iam/agreements/listMine` (requires a credential) · **Browser client:** `client.agreements.listMine()` * **Permission:** None beyond an ordinary session of the tenant. * **Audited as:** Not audited; it only reads. * **Errors:** `ACCESS_DENIED` from a role session or another tenant's session. Agreements you still owe come first, required ones before optional ones. Each entry carries `accepted` plus, when you accepted some version, `acceptedAt` and `acceptedVersion`, so you can tell "never accepted" from "accepted an older version". Use it to render a banner or an acceptance screen; `useAgreements` does this in React and Vue apps. ```ts title="Signature" iam.api.agreements.listMine( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## status [#status] Reports who accepted an agreement's current version and which active people still owe it. **HTTP:** `POST /api/iam/agreements/status` (requires a credential) · **Browser client:** `client.agreements.status()` * **Permission:** `iam:agreements:read` on the agreement. * **Audited as:** `iam:agreements:read`. * **Errors:** `NOT_FOUND` when the agreement is not in this tenant. `accepted` lists people with a current acceptance (version and time). `pending` lists every other active person, with `acceptedVersion` when they accepted an older version or their acceptance lapsed. Only people are reported, not service accounts. Use it to chase stragglers before you turn on a policy that denies on `principal.pendingAgreements`. ```ts title="Signature" iam.api.agreements.status( credential: CredentialInput, input: { tenantId: string; agreementId: string }, ): Promise ``` ## update [#update] Edits an agreement, optionally publishing the change as a new version that everyone must accept again. **HTTP:** `POST /api/iam/agreements/update` (requires a credential) · **Browser client:** `client.agreements.update()` * **Permission:** `iam:agreements:manage` on the agreement. * **Audited as:** `iam:agreements:manage`. * **Errors:** `NOT_FOUND` when the agreement is not in this tenant; `CONFLICT` for a name another agreement uses; `INVALID_INPUT` for the same validation as `create`; `INVARIANT_VIOLATION` when the change would break an enforced invariant. Fields you omit keep their values. `newVersion: true` increments the version, so every existing acceptance stops counting; without it acceptances stay valid even if you change the text. `reacceptAfterDays: null` removes the lapse, and an empty `url` removes the link. A new `reacceptAfterDays` applies to existing acceptances at once, measured from when each was given. Policies match `principal.agreements` by name, so renaming an agreement changes which statements match it. ```ts // Material change: everyone accepts again. await iam.api.agreements.update(credential, { tenantId, agreementId, content: 'Use company systems for work. Report incidents within 4 hours.', newVersion: true, }); ``` ```ts title="Signature" iam.api.agreements.update( credential: CredentialInput, input: Partial> & { tenantId: string; agreementId: string; newVersion?: boolean; reacceptAfterDays?: number | null; }, ): Promise ``` # analysis (/docs/reference/api/analysis) > Access analysis scans a tenant's configuration for risky or stale access, such as administrators without MFA or dormant accounts that still hold roles. Access analysis scans a tenant's configuration for risky or stale access, such as administrators without MFA or dormant accounts that still hold roles. It also reports API keys nobody uses, broken manager links, and more. It answers "what should we fix first?" without anyone reading every policy by hand, and a policy linter catches documents that do not do what their author intended. Nothing here changes access: findings are observations you act on through the other groups, or suppress with a recorded reason when a risk is accepted. ## What the scan checks [#what-the-scan-checks] [`findings`](#findings) runs every check below in one read-only transaction. "Administrator" means a tenant owner or anyone who holds a role that grants every action (`*` or `iam:*`) on every resource without conditions. Protected system policies and the Owner role are not reported against. | Kind | Severity | Reported when | | ------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `unrestricted-admin-policy` | high | A policy or a role's inline document allows every action on every resource without conditions. | | `admin-without-mfa` | high | A human administrator has neither an authenticator app nor a passkey, and the tenant does not require MFA. | | `separation-of-duties` | high | An active person holds roles that a [separation-of-duties rule](/docs/guides/authorization/separation-of-duties) forbids together. | | `team-maintainers-grant-admin` | high | A [team](/docs/reference/api/teams) (or a team above it) holds an administrator role as standing access, and maintainers manage its membership, so they can make anyone an administrator. | | `broad-action-wildcard` | medium | A policy allows a service-wide wildcard such as `documents:*` without conditions, which also grants actions added later. | | `service-account-admin` | medium | A service account is an administrator, so a leaked API key would control the organization. | | `dormant-access` | medium | A person holding bindings or ownership has not signed in for `dormantDays` (an account that never signed in counts from its creation). | | `stale-api-key` | medium | An unexpired API key has not been used for `dormantDays`. | | `trust-without-mfa` | medium | A live [trust](/docs/reference/api/trust) lets its source assume a role without MFA. | | `standing-privileged-access` | medium | A person holds an administrator role through a direct, permanent binding that is not eligible (just-in-time). | | `manager-cycle` | medium | A person's manager chain loops back to them. | | `unattached-policy` | low | A policy is not attached to any role. | | `unused-role` | low | A role is not bound to anyone and no trust uses it. | | `empty-role` | low | A role has no inline document and no attached policies. | | `empty-group-with-access` | low | A group holds role bindings but has no members, so anyone added later inherits them at once. | | `unused-eligible-binding` | low | An eligible binding has existed for longer than `dormantDays` without a recorded activation. | | `orphaned-manager` | low | A person's manager no longer exists or is not active, so manager approvals cannot reach them. | | `policy-lint` | low | A stored policy or a role's inline document has linter warnings of severity `warning` (see [`lintPolicy`](#lintpolicy)). | | `team-without-maintainer` | low | A team has members but no active maintainer (in it or above it), so only administrators can manage it. | | `department-without-head` | low | A [department](/docs/reference/api/departments) has people but no active head, so manager approvals routed through the org chart stop there. | Each finding has a title, a detail that says what to do, and the subject it concerns (a policy, role, identity, group, trust, credential, delegation, team, or department). ## Suppressing findings [#suppressing-findings] Some findings describe accepted risk: a break-glass administrator account, or a service account that must be powerful. Suppress them with a reason so they stop cluttering the results. Finding IDs are deterministic: the same condition on the same subject always yields the same 24-character ID, so a suppression keeps applying for as long as the condition holds, and again if it returns later. Suppressed findings are counted in `summary.suppressed` and listed, with who suppressed them, when, and why, when you pass `includeSuppressed: true`. | Method | What it does | Access | | --------------------------- | --------------------------------------------------------------------------------------------------------------------- | ---------- | | [`findings`](#findings) | Runs every access-analysis check on the tenant and returns the findings, most severe first, with counts per severity. | Credential | | [`lintPolicy`](#lintpolicy) | Checks a policy document, or a stored policy, for errors and for statements that likely do not do what they say. | Credential | | [`suppress`](#suppress) | Hides one finding from future results and records why. | Credential | | [`unsuppress`](#unsuppress) | Shows a suppressed finding again. | Credential | ## findings [#findings] Runs every access-analysis check on the tenant and returns the findings, most severe first, with counts per severity. **HTTP:** `POST /api/iam/analysis/findings` (requires a credential) · **Browser client:** `client.analysis.findings()` * **Permission:** `iam:analysis:read` on `iam/analysis/*`. * **Audited as:** `iam:analysis:read`. * **Errors:** `INVALID_INPUT` when `dormantDays` is outside 1 to 3650. `dormantDays` (default 90) sets when an unused account, API key, or eligible binding is reported. Suppressed findings are left out unless `includeSuppressed` is `true`. Run it on a schedule and alert on new high findings; the `analyze` [CLI command](/docs/reference/cli#analyze) does this with `--fail-on high`, which suits a CI or cron job. ```ts const { summary, findings } = await iam.api.analysis.findings(credential, { tenantId, dormantDays: 60 }); for (const finding of findings.filter((item) => item.severity === 'high')) console.log(finding.title, finding.detail); ``` ```ts title="Signature" iam.api.analysis.findings( credential: CredentialInput, input: { tenantId: string; dormantDays?: number; includeSuppressed?: boolean }, ): Promise<{ generatedAt: number; dormantDays: number; summary: { high: number; medium: number; low: number; suppressed: number }; findings: AccessFinding[]; }> ``` ## lintPolicy [#lintpolicy] Checks a policy document, or a stored policy, for errors and for statements that likely do not do what they say. **HTTP:** `POST /api/iam/analysis/lintPolicy` (requires a credential) · **Browser client:** `client.analysis.lintPolicy()` * **Permission:** `iam:policies:read` on the policy (with `policyId`) or on the tenant (with a candidate `document`). * **Audited as:** `iam:policies:read`. * **Errors:** `INVALID_INPUT` unless exactly one of `document` and `policyId` is given; `NOT_FOUND` when the policy is not in this tenant. The document is first validated the way storage would validate it, including unknown actions and resource types. A document storage would reject is not thrown as an error: the result has `valid: false`, the `error` code and message, and no warnings. A valid document gets `warnings`, each with a `code`, a `severity` (`warning`: probably a mistake; `info`: worth a look, often intended), the statement index, and a message. Examples include an allow that makes every holder a full administrator, a condition key the server never sets, a deny that silently never applies when an optional key is missing, an allow that an unconditional deny shadows, and duplicate statements. Use it in a policy editor before saving, or in CI over documents kept in version control. Pass `contextKeys` to name keys your application supplies through `resolveContext`, so they are not reported as unknown. See [policies](/docs/guides/authorization/policies) and [conditions](/docs/guides/authorization/conditions). ```ts const result = await iam.api.analysis.lintPolicy(credential, { tenantId, document: { version: 1, statements: [{ effect: 'allow', actions: ['documents:*'], resources: ['*'] }], }, }); // result.valid === true; result.warnings[0].code === 'service-wildcard' ``` ```ts title="Signature" iam.api.analysis.lintPolicy( credential: CredentialInput, input: { tenantId: string; document?: unknown; policyId?: string; contextKeys?: string[]; }, ): Promise ``` ## suppress [#suppress] Hides one finding from future results and records why. **HTTP:** `POST /api/iam/analysis/suppress` (requires a credential) · **Browser client:** `client.analysis.suppress()` * **Permission:** `iam:analysis:update` on `iam/analysis/{findingId}`. * **Audited as:** `iam:analysis:update`. * **Errors:** `INVALID_INPUT` when `findingId` is not a 24-character finding ID, or `reason` is empty or longer than 500 characters. The reason, the caller, and the time are kept and shown to anyone who lists suppressed findings, so reviewers can see who accepted which risk. Suppressing a finding again replaces its reason. ```ts title="Signature" iam.api.analysis.suppress( credential: CredentialInput, input: { tenantId: string; findingId: string; reason: string }, ): Promise<{ suppressed: boolean }> ``` ## unsuppress [#unsuppress] Shows a suppressed finding again. **HTTP:** `POST /api/iam/analysis/unsuppress` (requires a credential) · **Browser client:** `client.analysis.unsuppress()` * **Permission:** `iam:analysis:update` on `iam/analysis/{findingId}`. * **Audited as:** `iam:analysis:update`. Unsuppressing a finding that is not suppressed succeeds and changes nothing. ```ts title="Signature" iam.api.analysis.unsuppress( credential: CredentialInput, input: { tenantId: string; findingId: string }, ): Promise<{ suppressed: boolean }> ``` # assertions (/docs/reference/api/assertions) > Assertions are short-lived signed tokens that tell another service who is calling: the caller's identity, tenant, roles, groups, and whether they used MFA. Assertions are short-lived signed tokens that tell another service who is calling: the caller's identity, tenant, roles, groups, and whether they used MFA. They solve the "internal service" problem: a reporting service or a worker behind your API needs to trust the caller, but should not share your session store, hold the deployment secret, or call Better IAM on every request. The downstream service verifies the token locally with a derived key. An assertion grants nothing inside Better IAM itself and cannot be used as a credential for it. ## Verifying assertions downstream [#verifying-assertions-downstream] An assertion is a compact JSON Web Token signed with HS256 under a key derived from the deployment secret. Give the downstream service that key, `iam.assertionKey()` (64 hex characters), never the secret itself, and verify each token with `verifyAssertion`: ```ts import { verifyAssertion } from 'better-iam'; const claims = verifyAssertion(token, { key: process.env.IAM_ASSERTION_KEY!, // iam.assertionKey(), or iam.assertionKeys() during a secret rotation audience: 'reports', issuer: 'https://identity.example.com', // optional: the deployment's base URL origin }); // claims.sub, claims.tid, claims.roles, claims.groups, claims.mfa ``` It checks the signature in constant time, requires `aud` to equal your `audience` (and `iss` your `issuer`, when given), and rejects tokens past `exp` or issued in the future, with 30 seconds of clock tolerance (`toleranceSeconds`). Any failure throws `INVALID_ASSERTION` (401). While you rotate the deployment secret, pass the array from [`iam.assertionKeys()`](/docs/reference/api#assertionkeys) so tokens signed under the previous secret keep verifying. In edge runtimes, `verifyAssertionToken` from `@better-iam/next/edge` applies the same rules with Web Crypto. The key is symmetric: a service that holds it can verify assertions and could also create them. Share it only with services you trust. Assertions cannot be revoked, which is why they are short-lived: disabling a person stops new assertions, but tokens already issued stay valid until they expire. ## Claims [#claims] | Claim | Meaning | | ---------------- | ------------------------------------------------------------------------------- | | `iss` | The deployment's base URL origin. | | `sub` | The caller's identity id. | | `aud` | The audience the token was issued for. | | `iat`, `exp` | Issue and expiry times, in Unix seconds. | | `jti` | A unique token id, for replay detection. | | `tid` | The tenant the assertion was issued for. | | `kind` | The caller's session kind: `user`, `api-key`, `role`, or `session-token`. | | `mfa` | Whether the session was verified with MFA. | | `method` | How the person signed in, when known. | | `impersonatorId` | The administrator behind a "view as" session; `sub` is then the member. | | `name`, `email` | The caller's display name, and email when set. | | `roles` | Sorted ids of the roles the caller holds right now, directly or through groups. | | `groups` | Sorted ids of the caller's current groups. | | `ext` | The extra claims you passed as `claims`. | `roles` counts only bindings that grant at this moment: started, not expired, inside their access window, and, for eligible bindings, activated. | Method | What it does | Access | | ----------------- | ----------------------------------------------------------------------------------------------- | ---------- | | [`issue`](#issue) | Issues a signed assertion about the caller for one audience, valid for five minutes by default. | Credential | ## issue [#issue] Issues a signed assertion about the caller for one audience, valid for five minutes by default. **HTTP:** `POST /api/iam/assertions/issue` (requires a credential) · **Browser client:** `client.assertions.issue()` * **Permission:** `iam:assertions:create` on `iam/{audience}`. * **Audited as:** `iam:assertions:create`, on the audience. * **Errors:** `INVALID_INPUT` when `audience` is not a URL-safe identifier, `ttlSeconds` is outside 10 to 3600, or `claims` exceeds 4 KiB of JSON or reuses a standard claim name; `ACCESS_DENIED` when the caller may not obtain assertions for that audience, or presents a session token (`sts.getSessionToken`) restricted by a session policy, including one inherited from a scoped API key (recorded as a denial). Because the permission is checked on the audience, administrators decide which roles may obtain tokens for which services: allow `iam:assertions:create` on `iam/reports` for analysts, and on `iam/billing-worker` only for the billing role. The audience starts with a letter or digit and may contain letters, digits, `.`, `_`, `:`, `/`, and `-`, up to 256 characters. For an assumed-role session, `roles` holds only the assumed role and `groups` is empty. Restricted session tokens are refused because the `roles` claim would describe more access than the token allows. ```ts // In your API: forward the caller to the reports service. const { token, expiresAt } = await iam.api.assertions.issue(credential, { tenantId, audience: 'reports', ttlSeconds: 120, claims: { requestId }, }); await fetch('https://reports.internal/run', { headers: { authorization: `Bearer ${token}` } }); ``` ```ts title="Signature" iam.api.assertions.issue( credential: CredentialInput, input: { tenantId: string; audience: string; ttlSeconds?: number; claims?: Record; }, ): Promise<{ token: string; expiresAt: number; claims: AssertionClaims }> ``` # audit (/docs/reference/api/audit) > The audit log records who did what in a tenant; this group searches it, verifies its tamper-evident hash chain, and exports it for archiving. The audit log records who did what in a tenant; this group searches it, verifies its tamper-evident hash chain, and exports it for archiving. Every provisioning operation, denial, sign-in, and access-lifecycle event is recorded. Compliance, incident response, and support all start with "who did what, when": the log answers that, and its hash chain lets you prove to an auditor that no record was altered or removed afterwards. See [the audit chain](/docs/guides/events/audit-chain). ## What an audit event holds [#what-an-audit-event-holds] Each event has an `id`, the tenant, the acting identity (`actorId`), the `action` (such as `iam:groups:update`, `auth:session:create`, or `binding:activate`), the `resourceId` it concerned, the `outcome` (`allow` or `deny`), and a `timestamp`. Depending on the event it also carries `metadata`, `rootOverride` when a root administrator acted through the override, `impersonatorId` when an administrator acted in a "view as" session, and the session the actor used. Events never contain passwords, tokens, or secrets. Every event also has a place in the tenant's hash chain: `sequence` (its position, from 1), `previousHash` (the hash of the event before it), and `hash` (SHA-256 over the event's canonical JSON). Changing, reordering, or deleting a stored event breaks the chain at that point, which [`verify`](#verify) detects. All three methods need `iam:audit:read` on the tenant, and each call is itself recorded as an `iam:audit:read` event, so reading the log leaves a trace. The `audit-verify` and `audit-export` [CLI commands](/docs/reference/cli#audit-verify) run the same checks and export with deployment access, and record nothing. | Method | What it does | Access | | ------------------- | ------------------------------------------------------------------------------------------------------------- | ---------- | | [`export`](#export) | Returns a page of the tenant's audit events in chain order as JSON Lines, ready to archive. | Credential | | [`list`](#list) | Searches the tenant's audit events, newest first, by actor, action, resource, outcome, and time range. | Credential | | [`verify`](#verify) | Checks the tenant's audit hash chain and reports whether any stored event was altered, reordered, or removed. | Credential | ## export [#export] Returns a page of the tenant's audit events in chain order as JSON Lines, ready to archive. **HTTP:** `POST /api/iam/audit/export` (requires a credential) · **Browser client:** `client.audit.export()` * **Permission:** `iam:audit:read` on the tenant. * **Audited as:** `iam:audit:read`. * **Errors:** `INVALID_INPUT` when `fromSequence` is below 1 or `limit` is outside 1 to 10 000. `body` holds one JSON event per line, including `sequence`, `previousHash`, and `hash`, so an archive can be verified later anywhere with `verifyAuditChain` from `@better-iam/core`. Start at `fromSequence` (default 1) and follow `nextSequence` until it is `undefined`; each page's last hash links to the next page's first event. `head` is the current end of the chain, useful for comparing against your archive. Archive pages as they are; exporting before [pruning](/docs/reference/api#pruneaudit) keeps the full history verifiable. ```ts let fromSequence: number | undefined = 1; while (fromSequence !== undefined) { const page = await iam.api.audit.export(credential, { tenantId, fromSequence, limit: 5000 }); if (page.count) await archive.append(`${page.body}\n`); fromSequence = page.nextSequence; } ``` ```ts title="Signature" iam.api.audit.export( credential: CredentialInput, input: { tenantId: string; fromSequence?: number; limit?: number }, ): Promise<{ format: 'jsonl'; count: number; body: string; firstSequence: number | undefined; lastSequence: number | undefined; nextSequence: number | undefined; head: { sequence: number; hash: string } | null; }> ``` ## list [#list] Searches the tenant's audit events, newest first, by actor, action, resource, outcome, and time range. **HTTP:** `POST /api/iam/audit/list` (requires a credential) · **Browser client:** `client.audit.list()` * **Permission:** `iam:audit:read` on the tenant. * **Audited as:** `iam:audit:read`. * **Errors:** `INVALID_INPUT` when `outcome` is not `allow` or `deny`, or `limit` (1 to 1000), `offset`, `from`, or `to` is out of range. `action` accepts a glob pattern such as `iam:bindings:*` or `binding:*`; `actorId`, `resourceId`, and `outcome` match exactly; `from` and `to` bound the timestamp (epoch milliseconds). `limit` defaults to 100. Use it for an activity feed, a person's history, or an investigation such as "every denial in the last hour". ```ts const denials = await iam.api.audit.list(credential, { tenantId, outcome: 'deny', from: Date.now() - 60 * 60 * 1000, }); ``` ```ts title="Signature" iam.api.audit.list( credential: CredentialInput, input: { tenantId: string; limit?: number; offset?: number; actorId?: string; action?: string; resourceId?: string; outcome?: 'allow' | 'deny'; from?: number; to?: number; }, ): Promise ``` ## verify [#verify] Checks the tenant's audit hash chain and reports whether any stored event was altered, reordered, or removed. **HTTP:** `POST /api/iam/audit/verify` (requires a credential) · **Browser client:** `client.audit.verify()` * **Permission:** `iam:audit:read` on the tenant. * **Audited as:** `iam:audit:read`. * **Errors:** `INVALID_INPUT` when `fromSequence` or `toSequence` is below 1. The check walks the events in sequence order: the sequences must be contiguous, each `previousHash` must equal the previous event's hash, and every hash must be recomputable from its event. A full verification also requires the stored chain head to match the last event, which catches events deleted from the end. `fromSequence` and `toSequence` verify a window against its own links only, without the head comparison. `valid` is the answer. On failure, `failure` names the sequence, the event ID, and the reason: `sequence-gap`, `previous-hash-mismatch`, `hash-mismatch`, or `head-mismatch`. `checked` counts verified events, and `unchained` counts older events recorded before the chain existed, which are never failures. After a prune the chain starts at a later sequence, and verification starts from there. Schedule it and alert when `valid` is `false`. A chain proves nothing was changed by someone without write access to both the events and the chain head; it cannot stop someone with full database access from rewriting both, so also export regularly to independent storage and compare heads. ```ts title="Signature" iam.api.audit.verify( credential: CredentialInput, input: { tenantId: string; fromSequence?: number; toSequence?: number }, ): Promise<{ head: { sequence: number; hash: string; updatedAt: number } | null; valid: boolean; checked: number; unchained: number; first?: number; last?: number; lastHash?: string; failure?: { sequence: number; id: string; reason: AuditChainFailure }; }> ``` # auth (/docs/reference/api/auth) > The auth group signs people in and lets them manage their own account security: passwords, one-time codes, passkeys, MFA, sessions, and recovery. The `auth` group signs people in and lets them manage their own account security: passwords, one-time codes, passkeys, MFA, sessions, and recovery. It is what your login page, your account settings page, and the links in your recovery emails call. Unlike the other groups, these methods are not checked against `iam:*` permissions. Each one is either public, because the caller is still proving who they are (sign-up, sign-in, MFA challenges, email links), or it acts only on the caller's own session and account. No method takes another person's ID: administrators manage other people's accounts through [`identities`](/docs/reference/api/identities) (`revokeSessions`, `requestPasswordReset`, `unlock`) and set the rules with [`tenants.setAuthPolicy`](/docs/reference/api/tenants#setauthpolicy). Changes are recorded as `auth:*` audit events with the person as actor and resource, and with `impersonatorId` when an administrator acted through [impersonation](/docs/guides/authentication/impersonation). They are written only when something happens, never as denials. The one kind of failure that is recorded is `auth:signin:fail`: an attempt that named a real, active account with a wrong password, code, or recovery code. The events land in the tenant's audit log, fan out to webhooks like every other event, and people read their own with `listSecurityEvents`. Over HTTP every method is `POST {basePath}/auth/{method}`. Methods that issue a session set the session cookie, and a remembered-device token travels in a cookie of its own (see [HTTP behaviour](/docs/guides/authentication/http)). The HTTP handler records the client's IP address and user agent for you. When you call these methods in-process, pass `{ headers: request.headers }` as the credential of an authenticated method, or wrap a public call in `iam.auth.withClient({ ip, userAgent }, fn)`, so network rules, per-address rate limits, and the sign-in record see the real client. ## Sign-in flow and MFA challenges [#sign-in-flow-and-mfa-challenges] Every way of signing in ends the same way. Once the first factor checks out (a password, a passwordless code or link, or a federated assertion), Better IAM decides whether the person also needs a second factor. MFA is required for root administrators, for anyone with an authenticator enrolled, and for people the tenant policy (`requireMfa`, or `requireMfaForOwners` for owners) or the deployment's `requireMfa` callback covers. The result, a `SignInResult`, has one of two shapes: * A session: `{ token, session }`. The token is the bearer secret, returned once (only its hash is stored); `session` is the stored session without its secret-derived fields. * An MFA challenge: `{ mfaRequired: true, challenge, enrollmentRequired, emailCodeAvailable?, passkeyAvailable? }`. The `challenge` is a single-use token, valid for five minutes, proving that the first factor passed. It is not a session: it only unlocks the calls below. | The challenge says | Next call | | ---------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `enrollmentRequired: false` (an authenticator is enrolled) | `verifyMfa` with a code from the authenticator, or `recoverMfa` with a recovery code | | `enrollmentRequired: true` (nothing enrolled) | `beginMfa`, then `confirmMfa`, to enroll an authenticator on the spot | | `emailCodeAvailable: true` | `requestMfaCode`, then `verifyMfa` with the emailed code | | `passkeyAvailable: true` | `beginPasskeyMfa`, then `finishPasskeyMfa` | A passkey sign-in (`finishPasskeyAuthentication`) skips this step: a user-verified passkey counts as both factors, so it always returns a session. **Remember this device.** `verifyMfa`, `confirmMfa`, and `finishPasskeyMfa` accept `rememberDevice: true` and then also return a `deviceToken` with its `deviceExpiresAt` (together an `MfaSessionResult`). Passing that token to `signIn` or `finishPasswordless` later satisfies the MFA requirement from that browser; an unknown or expired token simply leads to the normal challenge. The HTTP handler keeps the token in the `better-iam.device` cookie and adds it to those two calls for you. The deployment's `trustedDeviceLifetimeMs` (30 days by default) and the tenant's `trustedDeviceDays` cap how long a device is remembered, either can turn the feature off, and root administrators are never remembered. **Issuing the session.** Before a session is issued, the person must still be active in an active tenant, must have verified their email when the deployment requires it (`EMAIL_UNVERIFIED`), and must be connecting from a network the tenant accepts (`IP_NOT_ALLOWED`, `IP_BLOCKED`). The new session then: * records the sign-in `method` (`password`, `passwordless-email`, `passwordless-sms`, `passkey`, or `federated`), which policies see as `principal.authMethod`, and the client's IP address, user agent, and label; * carries the person's previous sign-in and the failed attempts since then as `session.previousSignIn`, for a "last sign-in" notice, and restarts that count; * ends the person's oldest sessions when the tenant caps concurrent sessions with `maxSessions`; * queues a `new-sign-in` email when the client is unfamiliar and sign-in notifications are on (never for a remembered device); * is audited as `auth:session:create` with the method and client, plus `auth:device:trust` when a device was remembered. **Failed attempts.** A wrong password, authenticator or emailed code, or recovery code for a real, active account is recorded after the refusal as `auth:signin:fail`, with a `reason` of `password`, `mfa`, or `recovery-code`. It counts toward the next session's `previousSignIn`, and with the deployment's `failedSignInAlerts` set, the person receives one `sign-in-failures` email when the streak reaches that number. Unknown addresses, disabled accounts, and rate-limited attempts are never recorded, so the trail cannot be used to discover accounts. The [sign-in methods](/docs/guides/authentication/sign-in-methods) and [MFA](/docs/guides/authentication/mfa) guides walk through each flow. ## Rate limits and tenant policy [#rate-limits-and-tenant-policy] Every public flow except `beginMfa`, and every authenticated flow that checks a password or code or sends a message, counts the attempt before it looks at any credential: 1. **Network blocks.** A client address covered by a live [`security.blockNetwork`](/docs/reference/api/security#blocknetwork) block, of the tenant or platform-wide, is refused with `IP_BLOCKED` before anything else, and no counter moves. 2. **Per-address counter.** When the deployment sets `rateLimits.ipAttempts`, all of a tenant's flows share one counter per client address (IPv6 addresses are counted per /64), which stops password spraying across many accounts. 3. **Per-subject counter.** Each flow counts against its own subject: the email address, phone number, or account it names, or the single-use token being redeemed. | Tier | Default attempts per 15-minute window | Used by | | --------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Ordinary | 10 (`rateLimits.attempts`) | `signIn`, `reauthenticate`, `changePassword`, `beginPasskeyAuthentication` with an email, and redeeming emailed tokens (`verifyEmail`, `resetPassword`, `confirmEmailChange`) | | Sensitive | 5 (`rateLimits.sensitiveAttempts`) | `signUp`, every call that sends an email or SMS, checking passwordless and phone codes, `confirmMfa`, every step that answers an MFA challenge, and finishing a passkey sign-in | | Discovery | 10 times the ordinary limit, per client address | `beginPasskeyAuthentication` without an email | Every attempt counts, successful or not. Second-factor steps count twice, per challenge and per person, because each correct password mints a new challenge with a fresh budget. A tenant policy's `maxAttempts` lowers the per-subject limits for that tenant (it can never raise them). An exhausted counter fails the call with `RATE_LIMITED` (429). The error carries `retryAfterMs`, the HTTP response adds a `Retry-After` header, and the browser client exposes it as `IamClientError.retryAfterMs`. Counters are stored in the IAM database unless you supply `rateLimits.limiter`, and [`identities.unlock`](/docs/reference/api/identities#unlock) clears a person's counters after a burst of failures. A tenant's [authentication policy](/docs/guides/authentication/tenant-policy) can only tighten what the deployment allows. Its effects on these methods: | Policy field | Effect | | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `allowedMethods` | A sign-in method outside the list fails with `METHOD_NOT_ALLOWED` before any credential is examined, so the answer never reveals whether a password was right: `password` (`signIn`, `reauthenticate`), `passwordless-email` and `passwordless-sms` (`startPasswordless`, `finishPasswordless`), `passkey` (`beginPasskeyAuthentication`, `finishPasskeyAuthentication`). | | `requireMfa`, `requireMfaForOwners` | Sign-ins return an MFA challenge, and people without a factor enroll on the spot. Sessions that did not pass MFA stop working with `MFA_REQUIRED`, and `disableMfa` is refused. | | `mfaEmailCodes` | Offers emailed codes (`emailCodeAvailable`) to people with no authenticator. | | `trustedDeviceDays` | Shortens "remember this device"; `0` turns it off. | | `allowedIpRanges` | Sessions are issued and used only from these networks; anything else fails with `IP_NOT_ALLOWED`. Needs recorded client addresses. | | `bindSessionsToIp` | A session works only from the address it was issued from; elsewhere it is refused with `SESSION_NETWORK_MISMATCH` (401) and recorded as `auth:session:mismatch`. | | `sessionLifetimeMs`, `sessionIdleTimeoutMs`, `maxSessions` | Shorter sessions, and a cap on concurrent sessions per person (the oldest ends). | | `maxAttempts` | Lower rate limits, as above. | | `minPasswordLength`, `passwordMinClasses`, `passwordRejectPersonalInfo`, `passwordHistory`, `passwordMaxAgeDays` | Checked by `signUp`, `resetPassword`, and `changePassword` (`WEAK_PASSWORD`, `PASSWORD_REUSED`); an expired password fails `signIn` with `PASSWORD_EXPIRED` until it is reset. The deployment's screening adds `BREACHED_PASSWORD`, and `PASSWORD_CHECK_UNAVAILABLE` when a fail-closed breach check cannot answer. | | `notifyNewSignIn` | Emails people about sessions from unfamiliar clients. | A tenant that does not exist, is suspended, or sits under a suspended parent fails every flow with `TENANT_UNAVAILABLE`. Features the deployment has not enabled fail with `FEATURE_DISABLED`: sign-up without `signUpEnabled`, password flows when `emailPassword` is `false`, a passwordless channel without `passwordlessEmail` or `passwordlessSms`, passkeys without `passkeys`, and anything that sends an email or SMS without `sendEmail` or `sendSms`. ## Sessions and recent authentication [#sessions-and-recent-authentication] Methods whose permission is "the caller's own session" act only on the person behind the credential you pass: `{ token }` with a session token, or `{ headers }` with the incoming request's headers, which carry the session cookie or an `Authorization: Bearer` token. Only a person's session works here. API keys and assumed-role sessions fail with `UNAUTHENTICATED`, as does a session that has expired, idled out, or been revoked. Each call re-checks the session too: it fails with `MFA_REQUIRED` when the person now needs MFA and the session did not pass it, and with `IP_NOT_ALLOWED` or `IP_BLOCKED` when its network is no longer accepted. Methods that change how someone signs in, or that end sessions, also need **recent authentication**: the session must have been established within the deployment's `recentAuthenticationMs` (five minutes by default). An older session fails with `RECENT_AUTH_REQUIRED`; call [`reauthenticate`](#reauthenticate), or sign in again, and retry with the new session. An impersonation session never qualifies, whatever its age, and fails with `IMPERSONATION_RESTRICTED`, so an administrator viewing as a member cannot change the member's password, factors, devices, or sessions. See [sessions](/docs/guides/authentication/sessions). Some changes end every session of the person, including the one that made the call, and also forget their remembered devices and pending challenges (sign-in challenges and emailed links): `changePassword`, `resetPassword`, `confirmEmailChange`, `confirmMfa`, `disableMfa`, and `deletePasskey`. That way, a password, address, or factor change always cuts off anyone who was using the old one. `confirmMfa` returns a fresh session; after the others, the person signs in again. | Method | What it does | Access | | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`beginMfa`](#beginmfa) | Starts enrolling a TOTP authenticator and returns its secret and an `otpauth://` URI to show as a QR code. | Public | | [`beginPasskeyAuthentication`](#beginpasskeyauthentication) | Starts a passkey sign-in and returns WebAuthn request options plus the `challengeId` to finish it with. | Public | | [`beginPasskeyMfa`](#beginpasskeymfa) | Starts answering an MFA challenge with a registered passkey instead of a code, returning WebAuthn request options bound to that sign-in. | Public | | [`beginPasskeyRegistration`](#beginpasskeyregistration) | Returns WebAuthn creation options for adding a passkey to the caller's account. | Credential | | [`changePassword`](#changepassword) | Replaces the caller's password after checking the current one, and signs the person out everywhere. | Credential | | [`confirmEmailChange`](#confirmemailchange) | Completes an email change from the link sent to the new address, then signs the person out everywhere. | Public | | [`confirmMfa`](#confirmmfa) | Checks the first code from a newly enrolled authenticator, turns MFA on, and returns a fresh session with ten recovery codes. | Public | | [`confirmPhoneVerification`](#confirmphoneverification) | Checks the six-digit SMS code from `startPhoneVerification` and saves the number as the caller's verified phone. | Credential | | [`deletePasskey`](#deletepasskey) | Removes one of the caller's passkeys and signs the person out everywhere. | Credential | | [`disableMfa`](#disablemfa) | Removes the caller's authenticator and recovery codes, and signs the person out everywhere. | Credential | | [`finishPasskeyAuthentication`](#finishpasskeyauthentication) | Verifies a passkey assertion and signs the person in with a session that has already passed MFA. | Public | | [`finishPasskeyMfa`](#finishpasskeymfa) | Verifies a passkey assertion for a pending sign-in and issues the session, optionally remembering the device. | Public | | [`finishPasskeyRegistration`](#finishpasskeyregistration) | Verifies the browser's registration response and saves the new passkey on the caller's account. | Credential | | [`finishPasswordless`](#finishpasswordless) | Redeems a magic-link token or one-time code and signs the person in, or returns an MFA challenge. | Public | | [`getSession`](#getsession) | Returns the caller's identity, their session, and the session limits in force, so a client can warn before an idle sign-out. | Credential | | [`listPasskeys`](#listpasskeys) | Lists the caller's passkeys, newest first, without key material. | Credential | | [`listSecurityEvents`](#listsecurityevents) | Returns the caller's own authentication trail, newest first: sign-ins, failed attempts, sign-outs, and changes to passwords, addresses, factors, passkeys, and devices. | Credential | | [`listSessions`](#listsessions) | Lists the caller's unexpired sessions in this tenant, marking the one making the call with `current: true`. | Credential | | [`listTrustedDevices`](#listtrusteddevices) | Lists the caller's remembered devices that have not expired, most recently used first, without their tokens. | Credential | | [`mfaStatus`](#mfastatus) | Summarizes what the caller has set up: whether an authenticator is enabled, how many recovery codes remain, how many passkeys and remembered devices they have, and whether this session passed MFA. | Credential | | [`reauthenticate`](#reauthenticate) | Checks the caller's password again and returns a new session that counts as recently authenticated, or an MFA challenge. | Credential | | [`recoverMfa`](#recovermfa) | Answers an MFA challenge with a single-use recovery code when the person has lost their authenticator. | Public | | [`regenerateRecoveryCodes`](#regeneraterecoverycodes) | Replaces all of the caller's recovery codes with ten new ones. | Credential | | [`renamePasskey`](#renamepasskey) | Changes the label of one of the caller's passkeys. | Credential | | [`requestEmailChange`](#requestemailchange) | Emails a confirmation link to the new address the caller wants to move to. | Credential | | [`requestEmailVerification`](#requestemailverification) | Emails a new verification link to an address that has not been verified yet. | Public | | [`requestMfaCode`](#requestmfacode) | Emails a six-digit one-time code that answers the given MFA challenge, for people with no authenticator. | Public | | [`requestPasswordReset`](#requestpasswordreset) | Emails a password-reset link to a person who has forgotten their password. | Public | | [`resetPassword`](#resetpassword) | Sets a new password with the token from a password-reset email and ends every session of the person. | Public | | [`revokeOtherSessions`](#revokeothersessions) | Ends every other session of the caller in this tenant ("sign out everywhere else") and returns how many ended. | Credential | | [`revokeSession`](#revokesession) | Ends one of the caller's sessions, such as one left open on a shared computer. | Credential | | [`revokeTrustedDevice`](#revoketrusteddevice) | Forgets one remembered device, so its next sign-in asks for MFA again. | Credential | | [`revokeTrustedDevices`](#revoketrusteddevices) | Forgets every remembered device of the caller and returns how many were removed. | Credential | | [`signIn`](#signin) | Checks an email address and password and returns a session, or an MFA challenge when a second factor is needed. | Public | | [`signOut`](#signout) | Ends the caller's current session. | Credential | | [`signUp`](#signup) | Registers a new person in a tenant with an email address and password, when self-registration is enabled. | Public | | [`startPasswordless`](#startpasswordless) | Sends a magic link or a one-time code for signing in without a password. | Public | | [`startPhoneVerification`](#startphoneverification) | Texts a six-digit code to a phone number the caller wants to verify. | Credential | | [`verifyEmail`](#verifyemail) | Marks an email address verified using the token from a verification email. | Public | | [`verifyMfa`](#verifymfa) | Answers an MFA challenge with a code from the person's authenticator, or an emailed code, and issues the session. | Public | ## beginMfa [#beginmfa] Starts enrolling a TOTP authenticator and returns its secret and an `otpauth://` URI to show as a QR code. **HTTP:** `POST /api/iam/auth/beginMfa` (no credential) · **Browser client:** `client.auth.beginMfa()` * **Permission:** None: public during sign-in, with the `{ tenantId, challenge }` of an `mfaRequired` result; or the caller's own session, [authenticated recently](#sessions-and-recent-authentication). * **Audited as:** not audited; `confirmMfa` records `auth:mfa:enable`. * **Errors:** `INVALID_CHALLENGE` when the sign-in challenge is invalid or expired; `MFA_REQUIRED` when a sign-in challenge is used by someone who already has an authenticator (they must use it instead); `MFA_ALREADY_ENABLED` (409) when a signed-in caller already has one; `RECENT_AUTH_REQUIRED` for an older session. The enrollment stays pending for ten minutes and is bound to the credential that started it, so `confirmMfa` must present the same sign-in challenge or the same session. Calling `beginMfa` again replaces a pending enrollment with a new secret. Over HTTP, send `{ tenantId, challenge }` in the body during sign-in, or `{}` with a session. ```ts // signIn returned { mfaRequired: true, enrollmentRequired: true, challenge }. const { secret, uri } = await iam.api.auth.beginMfa({ tenantId, challenge: result.challenge }); // Render `uri` as a QR code and show `secret` for manual entry, then call confirmMfa. ``` ```ts title="Signature" iam.api.auth.beginMfa( credential: MfaCredential, ): Promise<{ secret: string; uri: string }> ``` ## beginPasskeyAuthentication [#beginpasskeyauthentication] Starts a passkey sign-in and returns WebAuthn request options plus the `challengeId` to finish it with. **HTTP:** `POST /api/iam/auth/beginPasskeyAuthentication` (no credential) · **Browser client:** `client.auth.beginPasskeyAuthentication()` * **Permission:** None: public. * **Audited as:** not audited; `finishPasskeyAuthentication` records the session. * **Errors:** `FEATURE_DISABLED` when passkeys are not configured; `METHOD_NOT_ALLOWED` when the tenant does not allow `passkey`; `INVALID_CREDENTIALS` when `email` names no active person in the tenant; `RATE_LIMITED`. Without `email`, the options name no credential: the browser's passkey picker or autofill offers any discoverable passkey it holds for your site, and `finishPasskeyAuthentication` finds the account from the credential itself. That mode is rate limited per client address with ten times the ordinary allowance, because a login page starts one on every visit. With `email`, the options list that person's passkeys. The challenge is valid for five minutes, so refresh an autofill request that waits longer. See [passkeys](/docs/guides/authentication/passkeys). ```ts title="Signature" iam.api.auth.beginPasskeyAuthentication( input: { tenantId: string; email?: string }, ): Promise<{ challengeId: string; options: Awaited>; }> ``` ## beginPasskeyMfa [#beginpasskeymfa] Starts answering an MFA challenge with a registered passkey instead of a code, returning WebAuthn request options bound to that sign-in. **HTTP:** `POST /api/iam/auth/beginPasskeyMfa` (no credential) · **Browser client:** `client.auth.beginPasskeyMfa()` * **Permission:** None: public, with the `challenge` of an `mfaRequired` result that offered `passkeyAvailable`. * **Audited as:** not audited; `finishPasskeyMfa` records the session. * **Errors:** `FEATURE_DISABLED` when passkeys are not configured or the person has none registered; `INVALID_CHALLENGE` when the sign-in challenge is invalid or expired; `RATE_LIMITED` (counted per challenge and per person). The passkey challenge it returns is valid for five minutes, and the sign-in challenge must still be open when you call `finishPasskeyMfa`. ```ts title="Signature" iam.api.auth.beginPasskeyMfa( input: { tenantId: string; challenge: string }, ): Promise<{ challengeId: string; options: Awaited>; }> ``` ## beginPasskeyRegistration [#beginpasskeyregistration] Returns WebAuthn creation options for adding a passkey to the caller's account. **HTTP:** `POST /api/iam/auth/beginPasskeyRegistration` (requires a credential) · **Browser client:** `client.auth.beginPasskeyRegistration()` * **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication). * **Audited as:** not audited; `finishPasskeyRegistration` records `auth:passkey:create`. * **Errors:** `FEATURE_DISABLED` when passkeys are not configured; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`. The options require a discoverable credential and user verification, request no attestation, and exclude the passkeys the person already has. The challenge is bound to this session and valid for five minutes. ```ts // In the browser, with the typed client. import { startRegistration } from 'better-iam/client/passkeys'; const { challengeId, options } = await client.auth.beginPasskeyRegistration(); const response = await startRegistration({ optionsJSON: options }); await client.auth.finishPasskeyRegistration({ challengeId, response, name: 'Work laptop' }); ``` ```ts title="Signature" iam.api.auth.beginPasskeyRegistration( credentials: CredentialInput, ): Promise<{ challengeId: string; options: Awaited>; }> ``` ## changePassword [#changepassword] Replaces the caller's password after checking the current one, and signs the person out everywhere. **HTTP:** `POST /api/iam/auth/changePassword` (requires a credential) · **Browser client:** `client.auth.changePassword()` * **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication). * **Audited as:** `auth:password:change`; a wrong current password is recorded as `auth:signin:fail`. * **Errors:** `INVALID_CREDENTIALS` when `currentPassword` is wrong or the account has no password; `WEAK_PASSWORD`, `BREACHED_PASSWORD`, or `PASSWORD_REUSED` when the new password fails the deployment's screening or the tenant's rules; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`; `RATE_LIMITED`. Every session ends, including the one that made the call, along with remembered devices and pending challenges, so the person signs in again with the new password. A wrong current password counts as a failed attempt on the account, because someone holding a stolen session may be guessing it. People who have no password set one through `requestPasswordReset` and `resetPassword` instead. ```ts title="Signature" iam.api.auth.changePassword( credentials: CredentialInput, input: { currentPassword: string; password: string }, ): Promise<{ success: true }> ``` ## confirmEmailChange [#confirmemailchange] Completes an email change from the link sent to the new address, then signs the person out everywhere. **HTTP:** `POST /api/iam/auth/confirmEmailChange` (no credential) · **Browser client:** `client.auth.confirmEmailChange()` * **Permission:** None: public (the token from the `email-change` email is the proof). * **Audited as:** `auth:email:change`. * **Errors:** `INVALID_CHALLENGE` when the token is invalid, already used, or older than ten minutes; `UNAUTHENTICATED` when the session that requested the change has ended; `IDENTITY_EXISTS` (409) when another person in the tenant now has the address; `RATE_LIMITED`. The new address is marked verified, since following the link proves the person receives mail there. Tying the confirmation to the requesting session means a change requested from a session that has since been revoked cannot complete. Every session, remembered device, and pending challenge of the person then ends. ```ts title="Signature" iam.api.auth.confirmEmailChange( input: { tenantId: string; token: string }, ): Promise<{ success: true }> ``` ## confirmMfa [#confirmmfa] Checks the first code from a newly enrolled authenticator, turns MFA on, and returns a fresh session with ten recovery codes. **HTTP:** `POST /api/iam/auth/confirmMfa` (no credential) · **Browser client:** `client.auth.confirmMfa()` * **Permission:** None: public with the sign-in challenge (`credential: { tenantId, challenge }`), or the caller's own session, [authenticated recently](#sessions-and-recent-authentication); either way, the same credential that called `beginMfa`. * **Audited as:** `auth:mfa:enable`, then `auth:session:create` (and `auth:device:trust` with `rememberDevice`). * **Errors:** `INVALID_CHALLENGE` when there is no pending enrollment for this credential or it is older than ten minutes; `INVALID_MFA` for a wrong or already-used code; `MFA_REQUIRED` when a sign-in challenge is used by someone who already has an authenticator; `RECENT_AUTH_REQUIRED`; `RATE_LIMITED`. Show the recovery codes once and ask the person to store them somewhere safe: they are returned only here and by `regenerateRecoveryCodes`, and each works once with `recoverMfa`. Enabling MFA ends every existing session, remembered device, and pending challenge of the person, so the returned session is the only one left. It has passed MFA and keeps the original sign-in method; over HTTP it replaces the session cookie. ```ts const { token, recoveryCodes } = await iam.api.auth.confirmMfa({ credential: { tenantId, challenge: result.challenge }, code: '492039', rememberDevice: true, }); ``` ```ts title="Signature" iam.api.auth.confirmMfa( input: { credential: MfaCredential; code: string; rememberDevice?: boolean }, ): Promise ``` ## confirmPhoneVerification [#confirmphoneverification] Checks the six-digit SMS code from `startPhoneVerification` and saves the number as the caller's verified phone. **HTTP:** `POST /api/iam/auth/confirmPhoneVerification` (requires a credential) · **Browser client:** `client.auth.confirmPhoneVerification()` * **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication); the same session that started the verification. * **Audited as:** `auth:phone:verify`. * **Errors:** `INVALID_CHALLENGE` when the code is wrong or expired, or was sent to another session or another number; `PHONE_EXISTS` (409) when another person in the tenant has already verified the number; `INVALID_INPUT` when `phone` is not in E.164 format; `RECENT_AUTH_REQUIRED`; `RATE_LIMITED`. A verified phone lets the person sign in with SMS codes when the deployment enables `passwordlessSms`. ```ts title="Signature" iam.api.auth.confirmPhoneVerification( credentials: CredentialInput, input: { phone: string; code: string }, ): Promise<{ success: true }> ``` ## deletePasskey [#deletepasskey] Removes one of the caller's passkeys and signs the person out everywhere. **HTTP:** `POST /api/iam/auth/deletePasskey` (requires a credential) · **Browser client:** `client.auth.deletePasskey()` * **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication). * **Audited as:** `auth:passkey:delete`. * **Errors:** `NOT_FOUND` when the passkey is not one of the caller's; `LAST_AUTHENTICATOR` (409) when it is the last passkey and the person has no other way to sign in; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`. "Another way to sign in" means a password, or a verified email address or phone number with passwordless sign-in enabled for that channel. Every session, remembered device, and pending challenge ends, so no session opened with the removed passkey outlives it. ```ts title="Signature" iam.api.auth.deletePasskey( credentials: CredentialInput, input: { id: string }, ): Promise<{ success: true }> ``` ## disableMfa [#disablemfa] Removes the caller's authenticator and recovery codes, and signs the person out everywhere. **HTTP:** `POST /api/iam/auth/disableMfa` (requires a credential) · **Browser client:** `client.auth.disableMfa()` * **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication) and signed in with MFA. * **Audited as:** `auth:mfa:disable`. * **Errors:** `MFA_REQUIRED` when the session did not pass MFA, when the person is a root administrator, or when the tenant policy or the deployment's `requireMfa` callback requires MFA for them; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`. Registered passkeys are kept. Every session, remembered device, and pending challenge of the person ends. ```ts title="Signature" iam.api.auth.disableMfa( credentials: CredentialInput, ): Promise<{ success: true }> ``` ## finishPasskeyAuthentication [#finishpasskeyauthentication] Verifies a passkey assertion and signs the person in with a session that has already passed MFA. **HTTP:** `POST /api/iam/auth/finishPasskeyAuthentication` (no credential) · **Browser client:** `client.auth.finishPasskeyAuthentication()` * **Permission:** None: public. * **Audited as:** `auth:session:create` with method `passkey`. * **Errors:** `INVALID_CHALLENGE` when `challengeId` is invalid or expired; `INVALID_PASSKEY` when the passkey is not registered to an account in this tenant, its user handle does not match, or verification fails; `INVALID_INPUT` when `response` is missing; `METHOD_NOT_ALLOWED`; `FEATURE_DISABLED`; `EMAIL_UNVERIFIED`, `IP_NOT_ALLOWED`, or `IP_BLOCKED` from the session checks; `RATE_LIMITED`. The server checks the challenge, origin, relying-party ID, user verification, signature, and signature counter, then records the passkey's `lastUsedAt`. Because the passkey proves possession and user verification together, the result is always a session (`SessionResult`), never an MFA challenge. Over HTTP it sets the session cookie. ```ts // In the browser, with the typed client. import { startAuthentication } from 'better-iam/client/passkeys'; const { challengeId, options } = await client.auth.beginPasskeyAuthentication({ tenantId }); const response = await startAuthentication({ optionsJSON: options }); await client.auth.finishPasskeyAuthentication({ tenantId, challengeId, response }); ``` ```ts title="Signature" iam.api.auth.finishPasskeyAuthentication( input: { tenantId: string; challengeId: string; response: AuthenticationResponseJSON; }, ): Promise ``` ## finishPasskeyMfa [#finishpasskeymfa] Verifies a passkey assertion for a pending sign-in and issues the session, optionally remembering the device. **HTTP:** `POST /api/iam/auth/finishPasskeyMfa` (no credential) · **Browser client:** `client.auth.finishPasskeyMfa()` * **Permission:** None: public, with the `challengeId` from `beginPasskeyMfa`. * **Audited as:** `auth:session:create` (and `auth:device:trust` with `rememberDevice`). * **Errors:** `INVALID_CHALLENGE` when the passkey challenge, or the sign-in challenge it belongs to, is invalid or expired; `INVALID_PASSKEY` when the passkey is not the person's or verification fails; `INVALID_INPUT` when `response` is missing; `RATE_LIMITED` (counted per challenge and per person). Both challenges are consumed together, and the session keeps the method of the first factor, such as `password`. The result is an `MfaSessionResult`: the session, plus `deviceToken` and `deviceExpiresAt` when you asked to remember the device and the policy allows it. ```ts title="Signature" iam.api.auth.finishPasskeyMfa( input: { tenantId: string; challengeId: string; response: AuthenticationResponseJSON; rememberDevice?: boolean; }, ): Promise ``` ## finishPasskeyRegistration [#finishpasskeyregistration] Verifies the browser's registration response and saves the new passkey on the caller's account. **HTTP:** `POST /api/iam/auth/finishPasskeyRegistration` (requires a credential) · **Browser client:** `client.auth.finishPasskeyRegistration()` * **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication); the same session that called `beginPasskeyRegistration`. * **Audited as:** `auth:passkey:create`, with the passkey's name. * **Errors:** `INVALID_CHALLENGE` when the challenge is expired or belongs to another session; `INVALID_PASSKEY` when verification fails; `PASSKEY_EXISTS` (409) when the credential is already registered anywhere in the installation; `INVALID_INPUT` for an empty name or one over 64 characters; `RECENT_AUTH_REQUIRED`. `name` is optional. Without it, the passkey is labeled from what the authenticator reports about itself: "This device", "Phone", "Security key", or "Passkey". Returns the new passkey's `id` and `name`. ```ts title="Signature" iam.api.auth.finishPasskeyRegistration( credentials: CredentialInput, input: { challengeId: string; response: RegistrationResponseJSON; name?: string }, ): Promise<{ id: string; name: string }> ``` ## finishPasswordless [#finishpasswordless] Redeems a magic-link token or one-time code and signs the person in, or returns an MFA challenge. **HTTP:** `POST /api/iam/auth/finishPasswordless` (no credential) · **Browser client:** `client.auth.finishPasswordless()` * **Permission:** None: public. * **Audited as:** `auth:session:create` when a session is issued. * **Errors:** `INVALID_CHALLENGE` when the token is wrong, already used, older than five minutes, or issued for another destination, or when the person's address or phone changed since it was sent; `METHOD_NOT_ALLOWED`; `FEATURE_DISABLED` when the channel has been turned off; `RATE_LIMITED` (counted per destination). Pass the same `destination` the message went to: a value starting with `+` is read as a phone number, anything else as an email address. Signing in by email also marks the address verified. The result is a `SignInResult` (see [the sign-in flow](#sign-in-flow-and-mfa-challenges)); a `deviceToken` from "remember this device" satisfies MFA, and the HTTP handler adds it from the device cookie. ```ts title="Signature" iam.api.auth.finishPasswordless( input: { tenantId: string; destination: string; token: string; deviceToken?: string }, ): Promise ``` ## getSession [#getsession] Returns the caller's identity, their session, and the session limits in force, so a client can warn before an idle sign-out. **HTTP:** `POST /api/iam/auth/getSession` (requires a credential) · **Browser client:** `client.auth.getSession()` * **Permission:** The caller's own session. * **Audited as:** not audited. * **Errors:** `UNAUTHENTICATED` when the session is missing, expired, idle, or revoked. `limits` holds the tenant's `lifetimeMs` and `idleTimeoutMs`, `idleExpiresAt` (when the session lapses if nothing uses it again, never later than its absolute expiry), and `now`, the server's clock, so a client can correct for its own clock skew. Like every authenticated call, it counts as activity, which makes it the natural target for a "Stay signed in" button. The identity comes back without its password hash. ```ts title="Signature" iam.api.auth.getSession( credentials: CredentialInput, ): Promise<{ identity: SafeIdentity; session: SafeSession; limits: { lifetimeMs: number; idleTimeoutMs: number; idleExpiresAt: number; now: number; }; }> ``` ## listPasskeys [#listpasskeys] Lists the caller's passkeys, newest first, without key material. **HTTP:** `POST /api/iam/auth/listPasskeys` (requires a credential) · **Browser client:** `client.auth.listPasskeys()` * **Permission:** The caller's own session. * **Audited as:** not audited. Each entry has its `id`, `name`, `createdAt`, `lastUsedAt` (the last sign-in or MFA answer), `deviceType` (`singleDevice`, or `multiDevice` for a synced passkey), `backedUp`, `transports`, and, when the authenticator reports one, the `aaguid` that identifies its model. ```ts title="Signature" iam.api.auth.listPasskeys( credentials: CredentialInput, ): Promise ``` ## listSecurityEvents [#listsecurityevents] Returns the caller's own authentication trail, newest first: sign-ins, failed attempts, sign-outs, and changes to passwords, addresses, factors, passkeys, and devices. **HTTP:** `POST /api/iam/auth/listSecurityEvents` (requires a credential) · **Browser client:** `client.auth.listSecurityEvents()` * **Permission:** The caller's own session. * **Audited as:** not audited. * **Errors:** `INVALID_INPUT` when `limit` is not an integer from 1 to 200. It returns up to `limit` (50 by default) of the `auth:*` audit events the person was the actor of in this tenant. Each event carries `impersonatorId` when an administrator acted through impersonation, `sequence` (its position in the [audit chain](/docs/guides/events/audit-chain)), and `metadata` such as the client's `ip` and `userAgent`, the sign-in `method`, or a failure's `reason`. Use it for an account page's recent activity; the tenant's full log is [`audit.list`](/docs/reference/api/audit#list), which needs `iam:audit:read`. ```ts title="Signature" iam.api.auth.listSecurityEvents( credentials: CredentialInput, input?: { limit?: number } | undefined, ): Promise<{ id: string; action: string; timestamp: number; impersonatorId?: string; sequence?: number; metadata?: Record; }[]> ``` ## listSessions [#listsessions] Lists the caller's unexpired sessions in this tenant, marking the one making the call with `current: true`. **HTTP:** `POST /api/iam/auth/listSessions` (requires a credential) · **Browser client:** `client.auth.listSessions()` * **Permission:** The caller's own session. * **Audited as:** not audited. Each session shows its sign-in `method`, its `client` details, and its timestamps, never its token. Sessions an administrator opened as the person through impersonation appear too, with `impersonatorId`, so people can see them and end them with `revokeSession`. ```ts title="Signature" iam.api.auth.listSessions( credentials: CredentialInput, ): Promise<(SafeSession & { current: boolean })[]> ``` ## listTrustedDevices [#listtrusteddevices] Lists the caller's remembered devices that have not expired, most recently used first, without their tokens. **HTTP:** `POST /api/iam/auth/listTrustedDevices` (requires a credential) · **Browser client:** `client.auth.listTrustedDevices()` * **Permission:** The caller's own session. * **Audited as:** not audited. Each device records when it was remembered, when it expires, when it last vouched for a sign-in (`lastUsedAt`), and the client (IP address, user agent, label) that completed MFA. ```ts title="Signature" iam.api.auth.listTrustedDevices( credentials: CredentialInput, ): Promise ``` ## mfaStatus [#mfastatus] Summarizes what the caller has set up: whether an authenticator is enabled, how many recovery codes remain, how many passkeys and remembered devices they have, and whether this session passed MFA. **HTTP:** `POST /api/iam/auth/mfaStatus` (requires a credential) · **Browser client:** `client.auth.mfaStatus()` * **Permission:** The caller's own session. * **Audited as:** not audited. Use it to drive an account security page, for example to prompt for new recovery codes when `recoveryCodesRemaining` runs low. ```ts title="Signature" iam.api.auth.mfaStatus( credentials: CredentialInput, ): Promise<{ enabled: boolean; recoveryCodesRemaining: number; passkeys: number; trustedDevices: number; sessionMfa: boolean; }> ``` ## reauthenticate [#reauthenticate] Checks the caller's password again and returns a new session that counts as recently authenticated, or an MFA challenge. **HTTP:** `POST /api/iam/auth/reauthenticate` (requires a credential) · **Browser client:** `client.auth.reauthenticate()` * **Permission:** The caller's own session, but not an impersonation session. * **Audited as:** `auth:session:create` for the new session; a wrong password is recorded as `auth:signin:fail`. * **Errors:** `INVALID_CREDENTIALS` for a wrong password or an account without one; `METHOD_NOT_ALLOWED` when the tenant does not allow `password`; `IMPERSONATION_RESTRICTED`; `RATE_LIMITED`. Call it when an operation answers `RECENT_AUTH_REQUIRED`, then retry with the new session. It is a full sign-in: when the person needs MFA the result is an `mfaRequired` challenge (a remembered device does not skip it here), and the session comes from `verifyMfa` or another second-factor call. The old session stays valid; over HTTP the cookie switches to the new one. People without a password get a recent session by signing in again, for example with a passkey. ```ts const result = await iam.api.auth.reauthenticate({ headers: request.headers }, { password }); ``` ```ts title="Signature" iam.api.auth.reauthenticate( credentials: CredentialInput, input: { password: string }, ): Promise ``` ## recoverMfa [#recovermfa] Answers an MFA challenge with a single-use recovery code when the person has lost their authenticator. **HTTP:** `POST /api/iam/auth/recoverMfa` (no credential) · **Browser client:** `client.auth.recoverMfa()` * **Permission:** None: public, with the `challenge` of an `mfaRequired` result. * **Audited as:** `auth:mfa:recover`, then `auth:session:create`; a wrong code is recorded as `auth:signin:fail`. * **Errors:** `INVALID_MFA` when the code is wrong or already used, or the person has no authenticator enrolled; `INVALID_CHALLENGE` when the challenge is invalid or expired; `RATE_LIMITED` (counted per challenge and per person). The code is used up. The authenticator stays enrolled, so once signed in the person should check `mfaStatus` and replace their codes with `regenerateRecoveryCodes`. This call cannot remember the device. ```ts title="Signature" iam.api.auth.recoverMfa( input: { tenantId: string; challenge: string; code: string }, ): Promise ``` ## regenerateRecoveryCodes [#regeneraterecoverycodes] Replaces all of the caller's recovery codes with ten new ones. **HTTP:** `POST /api/iam/auth/regenerateRecoveryCodes` (requires a credential) · **Browser client:** `client.auth.regenerateRecoveryCodes()` * **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication) and signed in with MFA. * **Audited as:** `auth:mfa:recovery-codes`. * **Errors:** `MFA_REQUIRED` when the session did not pass MFA; `MFA_NOT_ENROLLED` when no authenticator is enabled; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`. The old codes stop working at once. Show the new ones one time: only their hashes are stored, so they cannot be read back. ```ts title="Signature" iam.api.auth.regenerateRecoveryCodes( credentials: CredentialInput, ): Promise<{ recoveryCodes: string[] }> ``` ## renamePasskey [#renamepasskey] Changes the label of one of the caller's passkeys. **HTTP:** `POST /api/iam/auth/renamePasskey` (requires a credential) · **Browser client:** `client.auth.renamePasskey()` * **Permission:** The caller's own session. * **Audited as:** `auth:passkey:rename`, with the new name. * **Errors:** `NOT_FOUND` when the passkey is not one of the caller's; `INVALID_INPUT` for an empty name or one over 64 characters. Unlike the other passkey changes, renaming does not need recent authentication. ```ts title="Signature" iam.api.auth.renamePasskey( credentials: CredentialInput, input: { id: string; name: string }, ): Promise ``` ## requestEmailChange [#requestemailchange] Emails a confirmation link to the new address the caller wants to move to. **HTTP:** `POST /api/iam/auth/requestEmailChange` (requires a credential) · **Browser client:** `client.auth.requestEmailChange()` * **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication). * **Audited as:** not audited; `confirmEmailChange` records `auth:email:change`. * **Errors:** `INVALID_INPUT` for an invalid address; `FEATURE_DISABLED` when email delivery is not configured; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`; `RATE_LIMITED`. The address does not change until someone with access to the new mailbox follows the `email-change` link, within ten minutes and while this session is still alive. Whether another person already uses the address is checked at confirmation. Administrators change an address directly with [`identities.update`](/docs/reference/api/identities#update). ```ts title="Signature" iam.api.auth.requestEmailChange( credentials: CredentialInput, input: { email: string }, ): Promise<{ success: true }> ``` ## requestEmailVerification [#requestemailverification] Emails a new verification link to an address that has not been verified yet. **HTTP:** `POST /api/iam/auth/requestEmailVerification` (no credential) · **Browser client:** `client.auth.requestEmailVerification()` * **Permission:** None: public. * **Audited as:** not audited; `verifyEmail` records `auth:email:verify`. * **Errors:** `FEATURE_DISABLED` when email delivery is not configured; `RATE_LIMITED`. It always succeeds, so it never reveals whether an account exists: the `verify-email` message, valid for 24 hours, goes out only when an active person in the tenant has that address unverified. Use it for a "resend verification email" button. ```ts title="Signature" iam.api.auth.requestEmailVerification( input: { tenantId: string; email: string }, ): Promise<{ success: true }> ``` ## requestMfaCode [#requestmfacode] Emails a six-digit one-time code that answers the given MFA challenge, for people with no authenticator. **HTTP:** `POST /api/iam/auth/requestMfaCode` (no credential) · **Browser client:** `client.auth.requestMfaCode()` * **Permission:** None: public, with the `challenge` of a sign-in that offered `emailCodeAvailable`. * **Audited as:** not audited. * **Errors:** `FEATURE_DISABLED` when emailed codes were not offered for this sign-in or the person has no verified address; `INVALID_CHALLENGE` when the challenge is invalid or expired; `RATE_LIMITED` (counted per challenge and per person). The code works once with `verifyMfa` until the returned `expiresAt`, which never outlives the sign-in challenge. Requesting again replaces the code. Emailed codes are offered only to people with nothing enrolled and a verified address, never to root administrators, and only when the tenant's or the deployment's `mfaEmailCodes` allows them. ```ts title="Signature" iam.api.auth.requestMfaCode( input: { tenantId: string; challenge: string }, ): Promise<{ success: true; expiresAt: number }> ``` ## requestPasswordReset [#requestpasswordreset] Emails a password-reset link to a person who has forgotten their password. **HTTP:** `POST /api/iam/auth/requestPasswordReset` (no credential) · **Browser client:** `client.auth.requestPasswordReset()` * **Permission:** None: public. * **Audited as:** not audited; `resetPassword` records `auth:password:reset`. * **Errors:** `FEATURE_DISABLED` when email delivery is not configured or password sign-in is turned off; `RATE_LIMITED`. It always succeeds, so it never reveals whether an account exists. The `password-reset` message goes out only to an active person whose address is verified, and its token is valid for ten minutes. Administrators can send the same email for a member with [`identities.requestPasswordReset`](/docs/reference/api/identities#requestpasswordreset). See [recovery](/docs/guides/authentication/recovery). ```ts title="Signature" iam.api.auth.requestPasswordReset( input: { tenantId: string; email: string }, ): Promise<{ success: true }> ``` ## resetPassword [#resetpassword] Sets a new password with the token from a password-reset email and ends every session of the person. **HTTP:** `POST /api/iam/auth/resetPassword` (no credential) · **Browser client:** `client.auth.resetPassword()` * **Permission:** None: public (the token is the proof). * **Audited as:** `auth:password:reset`. * **Errors:** `INVALID_CHALLENGE` when the token is invalid, already used, or expired; `WEAK_PASSWORD`, `BREACHED_PASSWORD`, or `PASSWORD_REUSED` when the new password fails the deployment's screening or the tenant's rules; `FEATURE_DISABLED` when password sign-in is turned off; `RATE_LIMITED`. Recovery never signs the person in and never removes MFA: they sign in afterwards with the new password and their second factor. Every session, remembered device, and pending challenge ends, including any other reset links. The new password also restarts the tenant's password-age clock (`passwordMaxAgeDays`), which is how someone whose password expired gets back in. ```ts title="Signature" iam.api.auth.resetPassword( input: { tenantId: string; token: string; password: string }, ): Promise<{ success: true }> ``` ## revokeOtherSessions [#revokeothersessions] Ends every other session of the caller in this tenant ("sign out everywhere else") and returns how many ended. **HTTP:** `POST /api/iam/auth/revokeOtherSessions` (requires a credential) · **Browser client:** `client.auth.revokeOtherSessions()` * **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication). * **Audited as:** `auth:session:revoke-others`. * **Errors:** `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`. This includes sessions an administrator opened as the person through impersonation. Remembered devices are not affected, so a device that is still remembered can sign in again without MFA; use `revokeTrustedDevices` to forget those too. ```ts title="Signature" iam.api.auth.revokeOtherSessions( credentials: CredentialInput, ): Promise<{ revoked: number }> ``` ## revokeSession [#revokesession] Ends one of the caller's sessions, such as one left open on a shared computer. **HTTP:** `POST /api/iam/auth/revokeSession` (requires a credential) · **Browser client:** `client.auth.revokeSession()` * **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication). * **Audited as:** `auth:session:revoke`. * **Errors:** `NOT_FOUND` when the session is not one of the caller's; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`. Take the id from `listSessions`. Any impersonation sessions the person opened through the ended session end with it. ```ts title="Signature" iam.api.auth.revokeSession( credentials: CredentialInput, input: { sessionId: string }, ): Promise<{ success: true }> ``` ## revokeTrustedDevice [#revoketrusteddevice] Forgets one remembered device, so its next sign-in asks for MFA again. **HTTP:** `POST /api/iam/auth/revokeTrustedDevice` (requires a credential) · **Browser client:** `client.auth.revokeTrustedDevice()` * **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication). * **Audited as:** `auth:device:revoke`. * **Errors:** `NOT_FOUND` when the device is not one of the caller's; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`. Sessions already issued on that device keep working; end them with `revokeSession`. ```ts title="Signature" iam.api.auth.revokeTrustedDevice( credentials: CredentialInput, input: { deviceId: string }, ): Promise<{ success: true }> ``` ## revokeTrustedDevices [#revoketrusteddevices] Forgets every remembered device of the caller and returns how many were removed. **HTTP:** `POST /api/iam/auth/revokeTrustedDevices` (requires a credential) · **Browser client:** `client.auth.revokeTrustedDevices()` * **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication). * **Audited as:** `auth:device:revoke`, once, when at least one device was removed. * **Errors:** `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`. Over HTTP it also clears the `better-iam.device` cookie in the calling browser. Existing sessions keep working. ```ts title="Signature" iam.api.auth.revokeTrustedDevices( credentials: CredentialInput, ): Promise<{ revoked: number }> ``` ## signIn [#signin] Checks an email address and password and returns a session, or an MFA challenge when a second factor is needed. **HTTP:** `POST /api/iam/auth/signIn` (no credential) · **Browser client:** `client.auth.signIn()` * **Permission:** None: public. * **Audited as:** `auth:session:create` when a session is issued; `auth:signin:fail` when a real, active account was given a wrong password. * **Errors:** `INVALID_CREDENTIALS` (401) for an unknown address, a wrong password, an inactive account, or an account without a password, all indistinguishable; `EMAIL_UNVERIFIED` when verification is required and still pending; `PASSWORD_EXPIRED` when the tenant's maximum password age has passed; `METHOD_NOT_ALLOWED`; `TENANT_UNAVAILABLE`; `IP_NOT_ALLOWED` or `IP_BLOCKED`; `FEATURE_DISABLED` when password sign-in is turned off; `RATE_LIMITED`. The tenant is never inferred from the email address: you always pass `tenantId`. Unknown addresses go through the same password-hash work as real ones, so response times do not reveal which accounts exist. Pass `deviceToken` from an earlier "remember this device" to skip the second factor in that browser. See [the sign-in flow](#sign-in-flow-and-mfa-challenges) for what to do with each result. ```ts const result = await iam.auth.withClient({ ip, userAgent }, () => iam.api.auth.signIn({ tenantId, email: 'ada@example.com', password }), ); if ('mfaRequired' in result) { // Ask for a code, then: iam.api.auth.verifyMfa({ tenantId, challenge: result.challenge, code }) } else { // result.token is the bearer token; result.session describes the new session. } ``` ```ts title="Signature" iam.api.auth.signIn( input: { tenantId: string; email: string; password: string; deviceToken?: string }, ): Promise ``` ## signOut [#signout] Ends the caller's current session. **HTTP:** `POST /api/iam/auth/signOut` (requires a credential) · **Browser client:** `client.auth.signOut()` * **Permission:** The caller's own session. * **Audited as:** `auth:session:revoke`. * **Errors:** `UNAUTHENTICATED` when the session has already ended. Impersonation sessions the person opened through this session end with it. Over HTTP the session cookie is cleared even when the sign-out is refused, for example because the session had already idled out. Remembered devices stay remembered; `revokeTrustedDevices` forgets them. ```ts title="Signature" iam.api.auth.signOut( credentials: CredentialInput, ): Promise<{ success: true }> ``` ## signUp [#signup] Registers a new person in a tenant with an email address and password, when self-registration is enabled. **HTTP:** `POST /api/iam/auth/signUp` (no credential) · **Browser client:** `client.auth.signUp()` * **Permission:** None: public. * **Audited as:** `auth:identity:create`. * **Errors:** `FEATURE_DISABLED` when `signUpEnabled` is off or password sign-in is turned off; `FORBIDDEN` for the root tenant, whose people only administrators create; `IDENTITY_EXISTS` (409) when the address is taken in this tenant; `LIMIT_EXCEEDED` when the tenant has reached its member limit; `WEAK_PASSWORD` or `BREACHED_PASSWORD`; `TENANT_UNAVAILABLE`; `RATE_LIMITED`. Sign-up does not sign the person in. When email verification is required (the default once sign-up is enabled), it queues a `verify-email` message valid for 24 hours and returns `verificationRequired: true`, and `signIn` fails with `EMAIL_UNVERIFIED` until `verifyEmail` succeeds. The new person is never an owner or a root administrator, and signing up grants no access by itself: bind roles with [`bindings.create`](/docs/reference/api/bindings#create) or add them to a group. ```ts title="Signature" iam.api.auth.signUp( input: { tenantId: string; email: string; name: string; password: string }, ): Promise<{ identity: SafeIdentity; verificationRequired: boolean }> ``` ## startPasswordless [#startpasswordless] Sends a magic link or a one-time code for signing in without a password. **HTTP:** `POST /api/iam/auth/startPasswordless` (no credential) · **Browser client:** `client.auth.startPasswordless()` * **Permission:** None: public. * **Audited as:** not audited; `finishPasswordless` records the session. * **Errors:** `INVALID_INPUT` for an unknown `channel` or `kind`, a malformed destination, or SMS with `kind: 'magic-link'`; `FEATURE_DISABLED` when the channel is not enabled; `METHOD_NOT_ALLOWED` when the tenant does not allow it; `RATE_LIMITED` (counted per destination). It always succeeds, whether or not the destination belongs to anyone, so it never reveals which accounts exist. A message goes out only to an active person with that email address, or with that phone number verified; SMS supports codes only. The magic-link token or six-digit code is valid for five minutes and works once, with `finishPasswordless`. The message uses the `magic-link` or `code` template, which your `sendEmail` or `sendSms` callback turns into an email or text. ```ts await iam.api.auth.startPasswordless({ tenantId, destination: 'ada@example.com', channel: 'email', kind: 'code', }); // Later, with the code the person typed: const result = await iam.api.auth.finishPasswordless({ tenantId, destination: 'ada@example.com', token: '038514', }); ``` ```ts title="Signature" iam.api.auth.startPasswordless( input: { tenantId: string; destination: string; channel: 'email' | 'sms'; kind: 'magic-link' | 'code'; }, ): Promise<{ success: true }> ``` ## startPhoneVerification [#startphoneverification] Texts a six-digit code to a phone number the caller wants to verify. **HTTP:** `POST /api/iam/auth/startPhoneVerification` (requires a credential) · **Browser client:** `client.auth.startPhoneVerification()` * **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication). * **Audited as:** not audited; `confirmPhoneVerification` records `auth:phone:verify`. * **Errors:** `INVALID_INPUT` when `phone` is not in E.164 format (such as `+14155550100`); `FEATURE_DISABLED` when SMS delivery is not configured; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`; `RATE_LIMITED`. The code, sent with the `phone-verify` template, is valid for five minutes and only from the session that requested it. ```ts title="Signature" iam.api.auth.startPhoneVerification( credentials: CredentialInput, input: { phone: string }, ): Promise<{ success: true }> ``` ## verifyEmail [#verifyemail] Marks an email address verified using the token from a verification email. **HTTP:** `POST /api/iam/auth/verifyEmail` (no credential) · **Browser client:** `client.auth.verifyEmail()` * **Permission:** None: public (the token is the proof). * **Audited as:** `auth:email:verify`. * **Errors:** `INVALID_CHALLENGE` when the token is invalid, already used, or older than 24 hours; `UNAUTHENTICATED` when the account is no longer active; `RATE_LIMITED`. It does not sign the person in: send them to your sign-in page afterwards. ```ts title="Signature" iam.api.auth.verifyEmail( input: { tenantId: string; token: string }, ): Promise<{ success: true }> ``` ## verifyMfa [#verifymfa] Answers an MFA challenge with a code from the person's authenticator, or an emailed code, and issues the session. **HTTP:** `POST /api/iam/auth/verifyMfa` (no credential) · **Browser client:** `client.auth.verifyMfa()` * **Permission:** None: public, with the `challenge` of an `mfaRequired` result. * **Audited as:** `auth:session:create` (and `auth:device:trust` with `rememberDevice`); a wrong code is recorded as `auth:signin:fail`. * **Errors:** `INVALID_MFA` for a wrong, expired, or already-used code; `MFA_NOT_ENROLLED` (403) when the person has no authenticator and no emailed code was requested; `INVALID_CHALLENGE` when the challenge is invalid or expired; `RATE_LIMITED` (counted per challenge and per person). Authenticator codes are six digits. The current 30-second step and one on either side are accepted, and each step only once, so a captured code cannot be replayed. With `rememberDevice: true` the result also carries a `deviceToken` for later sign-ins (see [the sign-in flow](#sign-in-flow-and-mfa-challenges)). Over HTTP it sets the session cookie and, when a device was remembered, the device cookie. ```ts const session = await iam.api.auth.verifyMfa({ tenantId, challenge: result.challenge, code: '492039', rememberDevice: true, }); ``` ```ts title="Signature" iam.api.auth.verifyMfa( input: { tenantId: string; challenge: string; code: string; rememberDevice?: boolean; }, ): Promise ``` # authorities (/docs/reference/api/authorities) > Grant authorities delegate the right to hand out access, with a ceiling on what anything granted under them may ever allow. Grant authorities delegate the right to hand out access, with a ceiling on what anything granted under them may ever allow. In a growing organization not every administrator should be able to grant everything: a support lead should give support roles to their team but never make someone a tenant administrator. Every role, policy, and binding records the authority it was created under, and that authority's ceiling bounds it for as long as it exists. The guide is [grant authorities](/docs/guides/authorization/roles#grant-authorities). ## How delegation works [#how-delegation-works] * **A ceiling is a boundary.** An authority's `ceiling` is a policy document. Whenever a grant issued under the authority is evaluated, the ceiling, and every ceiling above it in the chain, is applied as a boundary: whatever the role says, the result never exceeds them. Nothing is checked for containment when the authority is created; a child ceiling broader than its parent's is simply cut back by the parent at evaluation time. * **Authorities form a chain.** A new authority is a child of one of your own, so delegation only ever narrows. Root administrators receive a root-issued, unrestricted authority automatically, and a tenant's first owner receives the authority their invitation carried. * **Authority is not permission.** Holding an authority does not let anyone grant anything. They also need the permissions, such as `iam:bindings:create` on the roles they may bind. The two together mean "may bind these roles, and the result never exceeds this ceiling". * **Revocation cascades.** Revoking an authority disables every binding, role, policy, and API key issued under it, and under every authority delegated from it, at the next request. * **Edits stay with their authority.** Only the holder of the authority behind a binding, role, or policy, or root, may change or delete it. Records carry the id of their authority as `authorityId`, and [`identities.export`](/docs/reference/api/identities#export) lists the authorities a person holds. | Method | What it does | Access | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`create`](#create) | Delegates a new grant authority to an identity, bounded by a ceiling and by your own authority chain. | Credential | | [`revoke`](#revoke) | Withdraws a grant authority, so everything issued under it, and under authorities delegated from it, stops granting at the next request. | Credential | ## create [#create] Delegates a new grant authority to an identity, bounded by a ceiling and by your own authority chain. **HTTP:** `POST /api/iam/authorities/create` (requires a credential) · **Browser client:** `client.authorities.create()` * **Permission:** `iam:authorities:create` on the recipient (`iam/{identityId}`), an active grant authority of your own to delegate from, and a recently authenticated session. * **Audited as:** `iam:authorities:create`. * **Errors:** `RECENT_AUTH_REQUIRED` when your sign-in is not recent or you call from temporary credentials such as a role session; `IMPERSONATION_RESTRICTED` from a "view as" session; `ACCESS_DENIED` when you issue authority to yourself (only root may) or `parentAuthorityId` names an authority that is not yours or is revoked; `GRANT_AUTHORITY_REQUIRED` when you hold no active authority; `INVALID_POLICY`, `INVALID_ACTION`, or `INVALID_RESOURCE_TYPE` when the ceiling does not validate against the catalog; `NOT_FOUND` when the identity is not in this tenant. `parentAuthorityId` picks which of your authorities the new one hangs under; without it, your root-issued authority (root) or your first active delegated authority is used. Give the person a role with the matching `iam:*` permissions as well, or the authority lets them grant nothing. ```ts // The support lead may hand out support roles, and nothing they grant can exceed tickets and customer reads. const authority = await iam.api.authorities.create(credential, { tenantId, identityId: supportLead.id, ceiling: { version: 1, statements: [{ effect: 'allow', actions: ['tickets:*', 'customers:read'], resources: ['*'] }], }, }); ``` ```ts title="Signature" iam.api.authorities.create( credential: CredentialInput, input: { tenantId: string; identityId: string; ceiling: PolicyDocument; parentAuthorityId?: string; }, ): Promise ``` ## revoke [#revoke] Withdraws a grant authority, so everything issued under it, and under authorities delegated from it, stops granting at the next request. **HTTP:** `POST /api/iam/authorities/revoke` (requires a credential) · **Browser client:** `client.authorities.revoke()` * **Permission:** `iam:authorities:revoke` on the authority (`iam/{authorityId}`) and a recently authenticated session. You must hold the authority's parent (or be root), and you cannot revoke your own. * **Audited as:** `iam:authorities:revoke`. * **Errors:** `ACCESS_DENIED` ("Only superior authority can revoke this grant") when you do not hold the parent authority; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`; `NOT_FOUND` when the authority is not in this tenant; `INVARIANT_VIOLATION` when the loss of access would break an enforced [access invariant](/docs/reference/api/invariants). Use it when a delegated administrator changes teams or leaves; offboarding with [`identities.offboard`](/docs/reference/api/identities#offboard) does it for you. Removing someone's administrator role alone does not disable the access they provisioned; revoking their authority does. Revocation deletes nothing: bindings, roles, and policies issued under the authority remain but grant nothing, and API keys issued under it are denied on every check. There is no way to reinstate a revoked authority; delegate a new one and re-issue what is still needed under it. The result is the authority with `revoked: true`. ```ts title="Signature" iam.api.authorities.revoke( credential: CredentialInput, input: { tenantId: string; authorityId: string }, ): Promise<{ revoked: boolean; identityId: string; ceiling: PolicyDocument; parentAuthorityId?: string; id: string; tenantId: string; uniqueKey?: string; }> ``` # billing (/docs/reference/api/billing) > Billing tells an organization what it spends, on what, and who spent it: usage recorded on meters is priced from a rate card per billing account and month, the… Billing tells an organization what it spends, on what, and who spent it: usage recorded on meters is priced from a rate card per billing account and month, then shared out to the people, agents, teams, departments and projects that produced it. Budgets alert and can refuse usage, and the platform issues monthly invoices (statements) with credit applied. Invoicing works like Stripe or Orb: drafts, finalized, paid, uncollectible and void invoices; one-off invoice items; payments and credit notes; plans with fees, seats and meter prices; subscriptions with trials and prorations; coupons; payment reminders and a printable invoice. Server code records usage, takes payments and runs the jobs through `iam.billing` (no credential). The repository guide is `docs/billing.md`. ## How spend is computed [#how-spend-is-computed] A **billing account** is an organization, or a tenant below it with a billing profile of its own. For each account and month, every meter's total is priced once (tiers and free units apply to the account's total), and the cost is shared out to the daily roll-ups of usage by quantity; for `unique` meters every person or agent costs the same. Reported meters carry their own cost per event. Person, team, department and project spend therefore add up to the account's charges. Meters an account defines for itself are chargeback only (`internal`) and never appear on a statement. Usage is attributed when it is recorded: the identity, its direct teams (split evenly by default, see the `billing` option `teamAttribution`), and its department (or its first team's). An agent's usage counts toward its sponsor's teams and department. Money is in micros of the deployment currency (`costMicros`), with rounded `amount` fields in currency units. ## Permissions [#permissions] `iam:billing:read` reads spend, budgets, credits, profiles, invoices, plans, subscriptions and discounts; `iam:billing:manage` defines meters and prices in the tenant, budgets and profiles, and lets an account's billing managers subscribe to self-serve plans and redeem coupon codes; `iam:billing:record` records usage. Resources are `iam/billing`, `iam/billing/meters/{key}`, `iam/billing/budgets`, `iam/billing/profile`, `iam/billing/credits`, `iam/billing/statements`, `iam/billing/invoice-items`, `iam/billing/plans`, `iam/billing/subscriptions`, `iam/billing/coupons` and `iam/billing/discounts`. Credits, closing a month, invoice items, payments, credit notes, plans and coupons are for root administrators only; they act on any account's invoice by calling on the root tenant. | Method | What it does | Access | | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`accounts`](#accounts) | Lists the billing accounts in the tenant's subtree with this month's billable spend, projection, available credit and latest statement. | Credential | | [`anomalies`](#anomalies) | Lists the people, teams and meters whose spend on one day jumped far above their usual daily spend. | Credential | | [`cancelSubscription`](#cancelsubscription) | Cancels a subscription at the end of the month (default) or now. | Credential | | [`changePlan`](#changeplan) | Moves a subscription to another plan now, keeping its seats and what is left of its trial. | Credential | | [`check`](#check) | Reports whether the caller's own usage is within every enforced budget that covers it. | Credential | | [`closePeriod`](#closeperiod) | Issues statements for a month that has ended, for every billing account or only `accountId`'s. | Credential | | [`createBudget`](#createbudget) | Creates a spend budget for the tenant, a tenant below it, a team, a department or a person, per month, quarter or year. | Credential | | [`createCoupon`](#createcoupon) | Creates a coupon accounts can redeem by code. | Credential | | [`createCreditNote`](#createcreditnote) | Issues a credit note against a finalized, paid or uncollectible invoice. | Credential | | [`createInvoiceItem`](#createinvoiceitem) | Adds a one-off charge, or with a negative `amount` a credit, to the billing account's next invoice. | Credential | | [`createMeter`](#createmeter) | Defines a usage meter: a platform meter on the root tenant, a chargeback meter for the tenant's subtree elsewhere. | Credential | | [`createPlan`](#createplan) | Defines a plan on the platform (root) tenant. | Credential | | [`deactivateCoupon`](#deactivatecoupon) | Stops a coupon (by code) from being redeemed; accounts that redeemed it keep their discount. | Credential | | [`deleteBudget`](#deletebudget) | Deletes a budget and its alert history. | Credential | | [`deleteInvoiceItem`](#deleteinvoiceitem) | Deletes a pending invoice item. | Credential | | [`deleteMeter`](#deletemeter) | Deletes a meter that has never recorded usage, with its prices. | Credential | | [`deleteProfile`](#deleteprofile) | Removes a billing profile, so the tenant's usage rolls into its parent's account again. | Credential | | [`departmentSpend`](#departmentspend) | Reports a department's spend, with the departments below it, grouped by `identity` by default. | Credential | | [`exportSpend`](#exportspend) | Returns a spend report as a CSV file: one row per group with the amount, share, events and a column per meter's quantity. | Credential | | [`exportStatement`](#exportstatement) | Returns a statement as a CSV file: its lines, credit and total, then the breakdown by project, team, department and person. | Credential | | [`finalizeInvoice`](#finalizeinvoice) | Finalizes a draft invoice: recomputes it with the latest usage and invoice items, numbers it, seals it and emails it. | Credential | | [`getProfile`](#getprofile) | Returns the billing profile of the tenant (or of `targetTenantId` below it) and the account that pays for it. | Credential | | [`getStatement`](#getstatement) | Returns one statement with its lines, credit, breakdown and bill-to details, re-checking its content hash. | Credential | | [`getTerms`](#getterms) | Returns the contract terms of the tenant's billing account: discount, minimum monthly commitment and tax. | Credential | | [`grantCredit`](#grantcredit) | Grants credit to a billing account, which its statements draw on, earliest expiry first. | Credential | | [`listBudgets`](#listbudgets) | Lists the tenant's budgets with their spend, projection and the thresholds reached in the current window. | Credential | | [`listCoupons`](#listcoupons) | Lists the platform's coupons, newest first, with their redemptions. | Credential | | [`listCreditNotes`](#listcreditnotes) | Lists the credit notes of the billing accounts in the tenant's subtree, or of one `statementId`, newest first. | Credential | | [`listCredits`](#listcredits) | Lists the credit of the tenant's billing account with the available balance. | Credential | | [`listDiscounts`](#listdiscounts) | Lists the discounts (redeemed coupons) of the billing accounts in the tenant's subtree. | Credential | | [`listInvoiceItems`](#listinvoiceitems) | Lists invoice items of the billing accounts in the tenant's subtree, newest first, optionally by `status`. | Credential | | [`listMeters`](#listmeters) | Lists the meters that reach the tenant with the price that applies to its billing account this month. | Credential | | [`listPlans`](#listplans) | Lists the platform's plans with their fees, seats and meter prices. | Credential | | [`listPrices`](#listprices) | Returns a meter's rate card as the tenant may see it, and the entry that prices its account this month. | Credential | | [`listStatements`](#liststatements) | Lists the statements of the billing accounts in the tenant's subtree (all of them for the root), newest month first. | Credential | | [`listSubscriptions`](#listsubscriptions) | Lists the subscriptions of the billing accounts in the tenant's subtree, newest first. | Credential | | [`listUsage`](#listusage) | Lists raw usage events recorded in the tenant for a month, newest first. | Credential | | [`markPaid`](#markpaid) | Marks a finalized or uncollectible statement paid by recording a `manual` payment of the amount due, with an optional reference. | Credential | | [`markUncollectible`](#markuncollectible) | Writes a finalized invoice off as uncollectible; a later payment still settles it. | Credential | | [`mySpend`](#myspend) | Reports the caller's own spend: their usage and that of the agents they sponsor, with the budgets set on them. | Credential | | [`previewStatement`](#previewstatement) | Builds the statement a billing account would receive for a month (the current one so far by default), without issuing it. | Credential | | [`quote`](#quote) | Prices a quantity of a meter as a month total for the tenant's billing account. | Credential | | [`record`](#record) | Records usage of a meter in the tenant, attributed to an identity, their teams and department, with optional tags. | Credential | | [`recordMany`](#recordmany) | Records up to 100 usage events in one transaction, all or nothing. | Credential | | [`recordPayment`](#recordpayment) | Records a payment against a finalized or uncollectible invoice. | Credential | | [`redeemCoupon`](#redeemcoupon) | Redeems a coupon code for the billing account. | Credential | | [`removeDiscount`](#removediscount) | Ends a billing account's discount now. | Credential | | [`renderInvoice`](#renderinvoice) | Returns an invoice as a standalone HTML page to print or save as PDF. | Credential | | [`resumeSubscription`](#resumesubscription) | Undoes a cancellation at the end of the month before it takes effect. | Credential | | [`revokeCredit`](#revokecredit) | Withdraws what is left of a credit. | Credential | | [`setPrice`](#setprice) | Sets or removes a rate-card price for a meter the tenant defines: its list price, or a negotiated price for a tenant below it. | Credential | | [`setProfile`](#setprofile) | Creates or updates a billing profile: company, billing emails, tax ID, address, purchase order, cost center, payment terms. | Credential | | [`setTerms`](#setterms) | Sets a billing account's contract terms: a discount off the subtotal, a minimum monthly commitment, and the tax invoices add. | Credential | | [`spend`](#spend) | Reports the spend of the tenant and every tenant below it for a month, grouped and filtered. | Credential | | [`subscribe`](#subscribe) | Subscribes the billing account to a plan (id or key). | Credential | | [`teamSpend`](#teamspend) | Reports a team's spend, with the teams below it, grouped by `identity` by default. | Credential | | [`trend`](#trend) | Returns monthly totals for the last `months` months (1 to 24, default 6), with the filters of `spend`. | Credential | | [`updateBudget`](#updatebudget) | Changes a budget's name, amount, period, meters, thresholds, alerts or enforcement; its subject stays. | Credential | | [`updateMeter`](#updatemeter) | Renames a meter, changes its unit or description, or archives it. | Credential | | [`updatePlan`](#updateplan) | Changes a plan, by id or key: name, items, description, trial, self-serve, archived. | Credential | | [`updateSubscription`](#updatesubscription) | Changes a subscription's seats. | Credential | | [`voidStatement`](#voidstatement) | Voids a finalized statement: its credit, invoice items, coupons and advance-billed months come back and the month reopens. | Credential | ## accounts [#accounts] Lists the billing accounts in the tenant's subtree with this month's billable spend, projection, available credit and latest statement. **HTTP:** `POST /api/iam/billing/accounts` (requires a credential) · **Browser client:** `client.billing.accounts()` * **Permission:** `iam:billing:read` on `iam/billing`. * **Audited as:** `iam:billing:read`. On the root tenant this is every organization: the platform's receivables view. `monthToDateMicros` counts only meters defined above the account (what a statement would bill); `totalMicros` includes the account's own chargeback meters. ```ts title="Signature" iam.api.billing.accounts( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## anomalies [#anomalies] Lists the people, teams and meters whose spend on one day jumped far above their usual daily spend. **HTTP:** `POST /api/iam/billing/anomalies` (requires a credential) · **Browser client:** `client.billing.anomalies()` * **Permission:** `iam:billing:read` on `iam/billing`. * **Audited as:** `iam:billing:read`. * **Errors:** `INVALID_INPUT` for a malformed `day`, `baselineDays` outside 3 to 90, `factor` outside 1.1 to 1000, or a negative `minimum`. `day` defaults to yesterday in the billing time zone. A spike is spend at least `factor` (3) times the average over the `baselineDays` (14) before it and at least `minimum` (10 currency units) more; spending with no baseline counts when it reaches `minimum` (`factor: null`). The largest increases come first, at most 50. The daily job `iam.billing.detectAnomalies()` alerts on them once each (`billing:anomaly`, `spend-anomaly` email). ```ts const { anomalies } = await iam.api.billing.anomalies(admin, { tenantId, factor: 5 }); ``` ```ts title="Signature" iam.api.billing.anomalies( credential: CredentialInput, input: { tenantId: string } & AnomalyOptions, ): Promise<{ tenantId: string; anomalies: SpendAnomaly[] }> ``` ## cancelSubscription [#cancelsubscription] Cancels a subscription at the end of the month (default) or now. **HTTP:** `POST /api/iam/billing/cancelSubscription` (requires a credential) · **Browser client:** `client.billing.cancelSubscription()` * **Permission:** `iam:billing:manage` on `iam/billing/subscriptions` (self-serve plans, at the month's end only), or a root administrator. * **Audited as:** `billing:subscription-cancel`. * **Errors:** `ACCESS_DENIED` (403) for an immediate cancellation by anyone but a root administrator; `INVALID_TRANSITION` (409) for an ended subscription. At the month's end the subscription keeps running and `resumeSubscription` can undo it. With `atPeriodEnd: false` it ends now and the unused part of this month's advance fees and seats is credited as invoice items; arrears items bill the part of the month it ran. ```ts title="Signature" iam.api.billing.cancelSubscription( credential: CredentialInput, input: { tenantId: string; subscriptionId: string; atPeriodEnd?: boolean }, ): Promise<{ subscription: SubscriptionView; invoiceItems: InvoiceItemView[] }> ``` ## changePlan [#changeplan] Moves a subscription to another plan now, keeping its seats and what is left of its trial. **HTTP:** `POST /api/iam/billing/changePlan` (requires a credential) · **Browser client:** `client.billing.changePlan()` * **Permission:** `iam:billing:manage` on `iam/billing/subscriptions` when both plans are self-serve, or a root administrator. * **Audited as:** `billing:subscription-plan-change`. * **Errors:** `CONFLICT` (409) when the account already subscribes to the new plan; `INVALID_TRANSITION` (409) for an ended subscription or an archived plan; `INVALID_INPUT` for the same plan. The old subscription ends and a new one starts. The old plan's unused advance charges are credited and the new plan's charges for the rest of the month added, both as invoice items for the next invoice (`invoiceItems`). ```ts title="Signature" iam.api.billing.changePlan( credential: CredentialInput, input: { tenantId: string; subscriptionId: string; plan: string }, ): Promise<{ subscription: SubscriptionView; invoiceItems: InvoiceItemView[] }> ``` ## check [#check] Reports whether the caller's own usage is within every enforced budget that covers it. **HTTP:** `POST /api/iam/billing/check` (requires a credential) · **Browser client:** `client.billing.check()` * **Permission:** The caller's own session; the tenant must be the caller's tenant or one below it. * **Audited as:** not audited. * **Errors:** `ACCESS_DENIED` (403) for a tenant outside the caller's. Covering budgets are enforced tenant budgets on the tenant or an ancestor, and the caller's own, their teams' (with parent teams) and their department's (with the departments above it); `meter` narrows to budgets that count it. `blockedBy` names the first spent budget. Statuses may lag recorded usage by up to 30 seconds. Server code checks any identity with `iam.billing.check`. ```ts const verdict = await client.billing.check({ tenantId, meter: 'api-calls' }); if (!verdict.allowed) showBudgetBanner(verdict.blockedBy); ``` ```ts title="Signature" iam.api.billing.check( credential: CredentialInput, input: { tenantId: string; meter?: string }, ): Promise ``` ## closePeriod [#closeperiod] Issues statements for a month that has ended, for every billing account or only `accountId`'s. **HTTP:** `POST /api/iam/billing/closePeriod` (requires a credential) · **Browser client:** `client.billing.closePeriod()` * **Permission:** Root administrators only, called on the root tenant (`iam:billing:manage` on `iam/billing/periods`). * **Audited as:** `iam:billing:manage`; each statement as `billing:statement`. * **Errors:** `INVALID_INPUT` for the current or a future month, or when called on another tenant. `period` defaults to last month. Each invoice bills the month's usage of platform meters, the subscriptions' fees and seats (the month itself for arrears items, the next month for advance ones), and pending invoice items. Accounts already invoiced for the month and accounts with nothing to bill are skipped (`skipped.existing`, `skipped.empty`), so the job is safe to repeat. Coupons apply after the contract discount, credit earliest expiry first; invoices are emailed (`billing-statement`) to the profile's billing emails or the owners, and raw usage events past their retention are deleted (`sweptUsage`). With `draft: true` (or the option `billing.autoFinalize: false`) invoices are kept as drafts, refreshed on every run, and listed in `drafted`; finalize them with `finalizeInvoice`. Schedulers call `iam.billing.closePeriod()` instead. ```ts title="Signature" iam.api.billing.closePeriod( credential: CredentialInput, input: { tenantId: string; period?: string; accountId?: string; draft?: boolean }, ): Promise ``` ## createBudget [#createbudget] Creates a spend budget for the tenant, a tenant below it, a team, a department or a person, per month, quarter or year. **HTTP:** `POST /api/iam/billing/createBudget` (requires a credential) · **Browser client:** `client.billing.createBudget()` * **Permission:** `iam:billing:manage` on `iam/billing/budgets`. * **Audited as:** `billing:budget-create`. * **Errors:** `CONFLICT` (409) for a name already used (case-insensitive); `LIMIT_EXCEEDED` (409) past 200 budgets; `NOT_FOUND` for a subject outside the tenant; `INVALID_INPUT` for an amount of 0, a malformed meter key, more than 10 thresholds or notification addresses. `amount` is in currency units. `thresholds` (default 50, 80, 100 percent) and `forecastAlerts` (default on) drive the alerts that `iam.billing.checkBudgets()` sends once per window to the owners, the subject (the person, the team's maintainers or the department head) and `notify.emails`. `enforce` makes covered usage fail once the budget is spent. The result is the budget with its current standing. ```ts await iam.api.billing.createBudget(admin, { tenantId, name: 'Platform team monthly', subjectType: 'team', subjectId: platformTeamId, amount: 600, notify: { emails: ['finance@acme.test'] }, }); ``` ```ts title="Signature" iam.api.billing.createBudget( credential: CredentialInput, input: { tenantId: string } & BillingBudgetInput, ): Promise ``` ## createCoupon [#createcoupon] Creates a coupon accounts can redeem by code. **HTTP:** `POST /api/iam/billing/createCoupon` (requires a credential) · **Browser client:** `client.billing.createCoupon()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/coupons`), called on the root tenant. * **Audited as:** `billing:coupon`. * **Errors:** `CONFLICT` (409) for a code already used; `INVALID_INPUT` for a code that is not 3 to 32 letters, digits, `-` or `_`, both or neither of `percentOff` and `amountOff`, a percentage outside 0 to 100, `durationInMonths` without `duration: 'repeating'`, or a `redeemBy` in the past. Codes are stored in upper case. `duration` is `once` (default: the next invoice), `repeating` (invoices for `durationInMonths` months from the month of redemption) or `forever`. `maxRedemptions` caps how many accounts may redeem it. ```ts await iam.api.billing.createCoupon(root, { tenantId: rootTenantId, code: 'LAUNCH20', percentOff: 20, duration: 'repeating', durationInMonths: 3, }); ``` ```ts title="Signature" iam.api.billing.createCoupon( credential: CredentialInput, input: { tenantId: string; code: string; name?: string; percentOff?: number; amountOff?: number; duration?: BillingCoupon['duration']; durationInMonths?: number; maxRedemptions?: number; redeemBy?: number; }, ): Promise ``` ## createCreditNote [#createcreditnote] Issues a credit note against a finalized, paid or uncollectible invoice. **HTTP:** `POST /api/iam/billing/createCreditNote` (requires a credential) · **Browser client:** `client.billing.createCreditNote()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/statements`). * **Audited as:** `billing:credit-note`. * **Errors:** `INVALID_INPUT` for an amount above what is left to credit, an unknown `reason`, or a non-boolean `refund`; `INVALID_TRANSITION` (409) for a draft or void invoice. `amount` defaults to everything not yet credited. The note first reduces the amount due; the rest (a part already paid) becomes account credit, or with `refund: true` is recorded as refunded outside Better IAM. `applied` shows the split. Notes are numbered `{invoice}-CN-01`, `-CN-02`, ...; `reason` is `duplicate`, `fraudulent`, `order_change`, `product_unsatisfactory` or `other` (default), with an optional `memo`. An invoice the notes and payments cover is paid. ```ts title="Signature" iam.api.billing.createCreditNote( credential: CredentialInput, input: { tenantId: string; statementId: string; amount?: number; reason?: BillingCreditNote['reason']; memo?: string; refund?: boolean; }, ): Promise<{ statement: StatementSummary; creditNote: CreditNoteView }> ``` ## createInvoiceItem [#createinvoiceitem] Adds a one-off charge, or with a negative `amount` a credit, to the billing account's next invoice. **HTTP:** `POST /api/iam/billing/createInvoiceItem` (requires a credential) · **Browser client:** `client.billing.createInvoiceItem()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/invoice-items`), called on the account. * **Audited as:** `billing:invoice-item`. * **Errors:** `INVALID_INPUT` when the tenant is not a billing account, for an empty description, an amount beyond one billion, a quantity of 0, or more than 20 metadata entries; `BILLING_PERIOD_CLOSED` (409) for a `period` already invoiced. `amount` is per unit in currency units and `quantity` defaults to 1; the item's total is rounded to the cent. `period` bills it on the invoice for that month instead of the next one. When credit items exceed an invoice's charges the invoice totals 0 and the rest becomes account credit. ```ts await iam.api.billing.createInvoiceItem(root, { tenantId: acmeId, description: 'Onboarding workshop', amount: 500, }); ``` ```ts title="Signature" iam.api.billing.createInvoiceItem( credential: CredentialInput, input: { tenantId: string; description: string; amount: number; quantity?: number; period?: string; metadata?: Record; }, ): Promise ``` ## createMeter [#createmeter] Defines a usage meter: a platform meter on the root tenant, a chargeback meter for the tenant's subtree elsewhere. **HTTP:** `POST /api/iam/billing/createMeter` (requires a credential) · **Browser client:** `client.billing.createMeter()` * **Permission:** `iam:billing:manage` on `iam/billing/meters/{key}`. * **Audited as:** `billing:meter-create`. * **Errors:** `CONFLICT` (409) when a meter with the key already reaches the tenant; `LIMIT_EXCEEDED` (409) past 100 meters; `INVALID_INPUT` for a malformed key or a `unique` reported meter. Keys are 1 to 64 lowercase letters, digits, dots, underscores or hyphens, starting with a letter. `aggregation` is `sum` (default) or `unique` (distinct people and agents per month); `pricing` is `rate-card` (default) or `reported` (each event carries its cost). Neither can change later. ```ts title="Signature" iam.api.billing.createMeter( credential: CredentialInput, input: { tenantId: string; key: string; name: string; unit?: string; description?: string; aggregation?: BillingMeter['aggregation']; pricing?: BillingMeter['pricing']; }, ): Promise ``` ## createPlan [#createplan] Defines a plan on the platform (root) tenant. **HTTP:** `POST /api/iam/billing/createPlan` (requires a credential) · **Browser client:** `client.billing.createPlan()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/plans`), called on the root tenant. * **Audited as:** `billing:plan`. * **Errors:** `CONFLICT` (409) for a key already used; `LIMIT_EXCEEDED` (409) past 100 plans; `INVALID_INPUT` for a malformed key, 0 or more than 20 items, duplicate item ids, two items pricing one meter, or a malformed price. `items` are `fee` (`amount` per month), `seat` (`unitAmount` per seat and month, `includedSeats`), both billed in `advance` (default) or `arrears`, and `usage` (`meter` with a `price` that replaces the rate card for subscribers). `trialDays` (1 to 365) starts subscriptions with a free trial; `selfServe` lets account billing managers subscribe. ```ts await iam.api.billing.createPlan(root, { tenantId: rootTenantId, key: 'team', name: 'Team', selfServe: true, items: [ { id: 'platform', kind: 'fee', name: 'Platform fee', amount: 99 }, { id: 'seats', kind: 'seat', name: 'Seats', unitAmount: 12, includedSeats: 3 }, ], }); ``` ```ts title="Signature" iam.api.billing.createPlan( credential: CredentialInput, input: { tenantId: string; key: string; name: string; items: unknown[]; description?: string; trialDays?: number; selfServe?: boolean; }, ): Promise ``` ## deactivateCoupon [#deactivatecoupon] Stops a coupon (by code) from being redeemed; accounts that redeemed it keep their discount. **HTTP:** `POST /api/iam/billing/deactivateCoupon` (requires a credential) · **Browser client:** `client.billing.deactivateCoupon()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/coupons`), called on the root tenant. * **Audited as:** `billing:coupon-deactivate`. * **Errors:** `NOT_FOUND` for an unknown code. ```ts title="Signature" iam.api.billing.deactivateCoupon( credential: CredentialInput, input: { tenantId: string; code: string }, ): Promise ``` ## deleteBudget [#deletebudget] Deletes a budget and its alert history. **HTTP:** `POST /api/iam/billing/deleteBudget` (requires a credential) · **Browser client:** `client.billing.deleteBudget()` * **Permission:** `iam:billing:manage` on `iam/billing/budgets`. * **Audited as:** `billing:budget-delete`. * **Errors:** `NOT_FOUND` for a budget of another tenant. ```ts title="Signature" iam.api.billing.deleteBudget( credential: CredentialInput, input: { tenantId: string; budgetId: string }, ): Promise<{ success: true }> ``` ## deleteInvoiceItem [#deleteinvoiceitem] Deletes a pending invoice item. **HTTP:** `POST /api/iam/billing/deleteInvoiceItem` (requires a credential) · **Browser client:** `client.billing.deleteInvoiceItem()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/invoice-items`), called on the account. * **Audited as:** `billing:invoice-item-delete`. * **Errors:** `INVALID_TRANSITION` (409) for an item already on a finalized invoice (issue a credit note instead). ```ts title="Signature" iam.api.billing.deleteInvoiceItem( credential: CredentialInput, input: { tenantId: string; itemId: string }, ): Promise<{ success: true }> ``` ## deleteMeter [#deletemeter] Deletes a meter that has never recorded usage, with its prices. **HTTP:** `POST /api/iam/billing/deleteMeter` (requires a credential) · **Browser client:** `client.billing.deleteMeter()` * **Permission:** `iam:billing:manage` on `iam/billing/meters/{key}`. * **Audited as:** `billing:meter-delete`. * **Errors:** `RESOURCE_IN_USE` (409) once the meter has recorded usage (archive it with `updateMeter` instead); `NOT_FOUND` when the tenant does not define the key. ```ts title="Signature" iam.api.billing.deleteMeter( credential: CredentialInput, input: { tenantId: string; key: string }, ): Promise<{ success: true; removedPrices: number }> ``` ## deleteProfile [#deleteprofile] Removes a billing profile, so the tenant's usage rolls into its parent's account again. **HTTP:** `POST /api/iam/billing/deleteProfile` (requires a credential) · **Browser client:** `client.billing.deleteProfile()` * **Permission:** `iam:billing:manage` on `iam/billing/profile`; below an organization, called from an ancestor with `targetTenantId`. * **Audited as:** `billing:profile-delete`. * **Errors:** `NOT_FOUND` without a profile; `ACCESS_DENIED` (403) when a tenant below an organization removes its own. ```ts title="Signature" iam.api.billing.deleteProfile( credential: CredentialInput, input: { tenantId: string; targetTenantId?: string }, ): Promise<{ success: true }> ``` ## departmentSpend [#departmentspend] Reports a department's spend, with the departments below it, grouped by `identity` by default. **HTTP:** `POST /api/iam/billing/departmentSpend` (requires a credential) · **Browser client:** `client.billing.departmentSpend()` * **Permission:** The department's head (or the head of a department above it), or `iam:billing:read`. * **Audited as:** `iam:billing:read`, with `metadata.via` `department-head` or `permission`. * **Errors:** `ACCESS_DENIED` (403) for anyone else; `NOT_FOUND` for a department outside the tenant. ```ts title="Signature" iam.api.billing.departmentSpend( credential: CredentialInput, input: { tenantId: string; departmentId: string; period?: string; groupBy?: SpendGroupBy; }, ): Promise ``` ## exportSpend [#exportspend] Returns a spend report as a CSV file: one row per group with the amount, share, events and a column per meter's quantity. **HTTP:** `POST /api/iam/billing/exportSpend` (requires a credential) · **Browser client:** `client.billing.exportSpend()` * **Permission:** `iam:billing:read` on `iam/billing`. * **Audited as:** `iam:billing:read`. * **Errors:** as `spend`. It takes the same input as `spend` and returns `filename`, `contentType` and `body` (RFC 4180, CRLF line endings, a final total row). Cells that a spreadsheet would run as a formula are prefixed with a quote. ```ts title="Signature" iam.api.billing.exportSpend( credential: CredentialInput, input: { tenantId: string } & SpendQuery, ): Promise ``` ## exportStatement [#exportstatement] Returns a statement as a CSV file: its lines, credit and total, then the breakdown by project, team, department and person. **HTTP:** `POST /api/iam/billing/exportStatement` (requires a credential) · **Browser client:** `client.billing.exportStatement()` * **Permission:** `iam:billing:read` on `iam/billing/statements`; the statement's account must be the tenant or below it. * **Audited as:** `iam:billing:read`. * **Errors:** `NOT_FOUND` for a statement outside the tenant's subtree. The file is named after the statement number. Department rows carry their cost center. ```ts title="Signature" iam.api.billing.exportStatement( credential: CredentialInput, input: { tenantId: string; statementId: string }, ): Promise ``` ## finalizeInvoice [#finalizeinvoice] Finalizes a draft invoice: recomputes it with the latest usage and invoice items, numbers it, seals it and emails it. **HTTP:** `POST /api/iam/billing/finalizeInvoice` (requires a credential) · **Browser client:** `client.billing.finalizeInvoice()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/statements`), called on the account or the root tenant. * **Audited as:** `billing:statement-finalize` and `billing:statement`. * **Errors:** `INVALID_TRANSITION` (409) for an invoice that is not a draft, or a draft with nothing left to bill. The invoice then consumes its credit, marks its invoice items `invoiced`, its subscriptions' advance months billed, and its coupons used. An invoice with nothing to pay is `paid` on issue. ```ts title="Signature" iam.api.billing.finalizeInvoice( credential: CredentialInput, input: { tenantId: string; statementId: string }, ): Promise ``` ## getProfile [#getprofile] Returns the billing profile of the tenant (or of `targetTenantId` below it) and the account that pays for it. **HTTP:** `POST /api/iam/billing/getProfile` (requires a credential) · **Browser client:** `client.billing.getProfile()` * **Permission:** `iam:billing:read` on `iam/billing/profile`. * **Audited as:** `iam:billing:read`. `account.inherited` is true when an ancestor pays; `profile` is null without a profile of its own. ```ts title="Signature" iam.api.billing.getProfile( credential: CredentialInput, input: { tenantId: string; targetTenantId?: string }, ): Promise ``` ## getStatement [#getstatement] Returns one statement with its lines, credit, breakdown and bill-to details, re-checking its content hash. **HTTP:** `POST /api/iam/billing/getStatement` (requires a credential) · **Browser client:** `client.billing.getStatement()` * **Permission:** `iam:billing:read` on `iam/billing/statements`; the statement's account must be the tenant or below it. * **Audited as:** `iam:billing:read`. * **Errors:** `NOT_FOUND` for a statement outside the tenant's subtree. `verified` is false when the stored content no longer matches the hash computed at issue. `overdue` is true for a finalized statement past its due date. ```ts title="Signature" iam.api.billing.getStatement( credential: CredentialInput, input: { tenantId: string; statementId: string }, ): Promise<{ total: number; overdue: boolean; verified: boolean; number: string; status: InvoiceStatus; issuedAt: number; dueAt: number; hash: string; payments?: InvoicePayment[]; amountPaidMicros?: number; creditNotesMicros?: number; invoiceItemIds?: string[]; advanceBilled?: { subscriptionId: string; period: string }[]; carryForward?: { creditId: string; amountMicros: number }; reminders?: { days: number; at: number; recipients: number }[]; paidAt?: number; paidBy?: string; paymentReference?: string; voidedAt?: number; voidedBy?: string; voidReason?: string; markedUncollectibleAt?: number; id: string; tenantId: string; uniqueKey?: string; period: string; currency: string; periodStart: number; periodEnd: number; lines: StatementLine[]; billingReason?: 'period' | 'subscription' | 'manual'; subtotalMicros: number; coupons?: { discountId: string; code: string; name: string; amountMicros: number }[]; discount?: { percent: number; amountMicros: number }; commitment?: { minimumMicros: number; trueUpMicros: number }; creditsMicros: number; creditsApplied: { creditId: string; amountMicros: number }[]; tax?: { label: string; ratePercent: number; amountMicros: number }; totalMicros: number; breakdown: { tenants: StatementAllocation[]; teams: StatementAllocation[]; departments: (StatementAllocation & { costCenter?: string })[]; identities: StatementAllocation[]; }; billTo: { name: string; companyName?: string; taxId?: string; address?: string; purchaseOrder?: string; costCenter?: string; emails: string[]; }; }> ``` ## getTerms [#getterms] Returns the contract terms of the tenant's billing account: discount, minimum monthly commitment and tax. **HTTP:** `POST /api/iam/billing/getTerms` (requires a credential) · **Browser client:** `client.billing.getTerms()` * **Permission:** `iam:billing:read` on `iam/billing/terms`. * **Audited as:** `iam:billing:read`. When an ancestor pays for the tenant the result only says `inherited: true`; without terms it lists none. ```ts title="Signature" iam.api.billing.getTerms( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## grantCredit [#grantcredit] Grants credit to a billing account, which its statements draw on, earliest expiry first. **HTTP:** `POST /api/iam/billing/grantCredit` (requires a credential) · **Browser client:** `client.billing.grantCredit()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/credits`). * **Audited as:** `billing:credit-grant`. * **Errors:** `INVALID_INPUT` when the tenant is not a billing account (an organization or a tenant with a profile), for an amount of 0, or an `expiresAt` in the past. ```ts await iam.api.billing.grantCredit(root, { tenantId: acmeId, amount: 100, reason: 'Launch promotion' }); ``` ```ts title="Signature" iam.api.billing.grantCredit( credential: CredentialInput, input: { tenantId: string; amount: number; reason: string; expiresAt?: number }, ): Promise ``` ## listBudgets [#listbudgets] Lists the tenant's budgets with their spend, projection and the thresholds reached in the current window. **HTTP:** `POST /api/iam/billing/listBudgets` (requires a credential) · **Browser client:** `client.billing.listBudgets()` * **Permission:** `iam:billing:read` on `iam/billing/budgets`. * **Audited as:** `iam:billing:read`. ```ts title="Signature" iam.api.billing.listBudgets( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listCoupons [#listcoupons] Lists the platform's coupons, newest first, with their redemptions. **HTTP:** `POST /api/iam/billing/listCoupons` (requires a credential) · **Browser client:** `client.billing.listCoupons()` * **Permission:** Root administrators only (`iam:billing:read` on `iam/billing/coupons`), called on the root tenant. * **Audited as:** `iam:billing:read`. `active` is false for deactivated coupons, those past `redeemBy`, and those out of redemptions. ```ts title="Signature" iam.api.billing.listCoupons( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listCreditNotes [#listcreditnotes] Lists the credit notes of the billing accounts in the tenant's subtree, or of one `statementId`, newest first. **HTTP:** `POST /api/iam/billing/listCreditNotes` (requires a credential) · **Browser client:** `client.billing.listCreditNotes()` * **Permission:** `iam:billing:read` on `iam/billing/statements`. * **Audited as:** `iam:billing:read`. ```ts title="Signature" iam.api.billing.listCreditNotes( credential: CredentialInput, input: { tenantId: string; statementId?: string }, ): Promise ``` ## listCredits [#listcredits] Lists the credit of the tenant's billing account with the available balance. **HTTP:** `POST /api/iam/billing/listCredits` (requires a credential) · **Browser client:** `client.billing.listCredits()` * **Permission:** `iam:billing:read` on `iam/billing/credits`. * **Audited as:** `iam:billing:read`. When an ancestor pays for the tenant the list is empty and `inherited` is true. ```ts title="Signature" iam.api.billing.listCredits( credential: CredentialInput, input: { tenantId: string }, ): Promise<{ accountId: string; inherited: boolean; balanceMicros: number; balance: number; credits: CreditView[]; }> ``` ## listDiscounts [#listdiscounts] Lists the discounts (redeemed coupons) of the billing accounts in the tenant's subtree. **HTTP:** `POST /api/iam/billing/listDiscounts` (requires a credential) · **Browser client:** `client.billing.listDiscounts()` * **Permission:** `iam:billing:read` on `iam/billing/discounts`. * **Audited as:** `iam:billing:read`. `appliedInvoices` counts the invoices a discount reduced; `active` says whether it applies to this month's invoice. ```ts title="Signature" iam.api.billing.listDiscounts( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listInvoiceItems [#listinvoiceitems] Lists invoice items of the billing accounts in the tenant's subtree, newest first, optionally by `status`. **HTTP:** `POST /api/iam/billing/listInvoiceItems` (requires a credential) · **Browser client:** `client.billing.listInvoiceItems()` * **Permission:** `iam:billing:read` on `iam/billing/invoice-items`. * **Audited as:** `iam:billing:read`. * **Errors:** `INVALID_INPUT` for a `status` other than `pending` or `invoiced`. `pending` items wait for the account's next invoice (or the one for their `period`); `invoiced` items carry the `statementId` that billed them. `source: 'proration'` items come from subscription changes. ```ts title="Signature" iam.api.billing.listInvoiceItems( credential: CredentialInput, input: { tenantId: string; status?: BillingInvoiceItem['status'] }, ): Promise ``` ## listMeters [#listmeters] Lists the meters that reach the tenant with the price that applies to its billing account this month. **HTTP:** `POST /api/iam/billing/listMeters` (requires a credential) · **Browser client:** `client.billing.listMeters()` * **Permission:** `iam:billing:read` on `iam/billing`. * **Audited as:** `iam:billing:read`. `scope` is `platform` for root meters; `inherited` marks meters an ancestor defines. ```ts title="Signature" iam.api.billing.listMeters( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listPlans [#listplans] Lists the platform's plans with their fees, seats and meter prices. **HTTP:** `POST /api/iam/billing/listPlans` (requires a credential) · **Browser client:** `client.billing.listPlans()` * **Permission:** `iam:billing:read` on `iam/billing/plans`. * **Audited as:** `iam:billing:read`. Tenants see plans that are not archived. Root administrators on the root tenant also get `subscribers` (live subscriptions) and, with `includeArchived`, archived plans. Amounts are in currency units. ```ts title="Signature" iam.api.billing.listPlans( credential: CredentialInput, input: { tenantId: string; includeArchived?: boolean }, ): Promise ``` ## listPrices [#listprices] Returns a meter's rate card as the tenant may see it, and the entry that prices its account this month. **HTTP:** `POST /api/iam/billing/listPrices` (requires a credential) · **Browser client:** `client.billing.listPrices()` * **Permission:** `iam:billing:read` on `iam/billing/meters/{key}`. * **Audited as:** `iam:billing:read`. * **Errors:** `NOT_FOUND` when no meter with the key reaches the tenant. Entries for the tenant, its ancestors (list prices) and tenants below it are listed, newest first; negotiated prices for other organizations are not. ```ts title="Signature" iam.api.billing.listPrices( credential: CredentialInput, input: { tenantId: string; meter: string }, ): Promise<{ effective?: PriceView | undefined; meter: MeterView; entries: PriceView[] }> ``` ## listStatements [#liststatements] Lists the statements of the billing accounts in the tenant's subtree (all of them for the root), newest month first. **HTTP:** `POST /api/iam/billing/listStatements` (requires a credential) · **Browser client:** `client.billing.listStatements()` * **Permission:** `iam:billing:read` on `iam/billing/statements`. * **Audited as:** `iam:billing:read`. Filter by `status` (`finalized`, `paid`, `void`) or `period`. ```ts title="Signature" iam.api.billing.listStatements( credential: CredentialInput, input: { tenantId: string; status?: BillingStatement['status']; period?: string }, ): Promise ``` ## listSubscriptions [#listsubscriptions] Lists the subscriptions of the billing accounts in the tenant's subtree, newest first. **HTTP:** `POST /api/iam/billing/listSubscriptions` (requires a credential) · **Browser client:** `client.billing.listSubscriptions()` * **Permission:** `iam:billing:read` on `iam/billing/subscriptions`. * **Audited as:** `iam:billing:read`. Ended subscriptions are left out unless `includeEnded`. `status` is `trialing`, `active` or `ended`; `billedAdvance` lists the months already billed in advance. ```ts title="Signature" iam.api.billing.listSubscriptions( credential: CredentialInput, input: { tenantId: string; includeEnded?: boolean }, ): Promise ``` ## listUsage [#listusage] Lists raw usage events recorded in the tenant for a month, newest first. **HTTP:** `POST /api/iam/billing/listUsage` (requires a credential) · **Browser client:** `client.billing.listUsage()` * **Permission:** `iam:billing:read` on `iam/billing`. * **Audited as:** `iam:billing:read`. Only the tenant itself (not its subtree), at most `limit` (1 to 500, default 100) events, optionally for one `meter` or identity. Events are kept `usageRetentionDays` after their month; reports use daily roll-ups and outlive them. ```ts title="Signature" iam.api.billing.listUsage( credential: CredentialInput, input: { tenantId: string; period?: string; meter?: string; identityId?: string; limit?: number; }, ): Promise<{ tenantId: string; period: string; events: { idempotencyKey?: string | undefined; meterId: string; meter: string; quantity: number; costMicros?: number; identityId?: string; agentId?: string; teamIds: string[]; departmentId?: string; tags?: Record; occurredAt: number; period: string; day: string; recordedAt: number; recordedBy: string; sourceId?: string; id: string; tenantId: string; }[]; }> ``` ## markPaid [#markpaid] Marks a finalized or uncollectible statement paid by recording a `manual` payment of the amount due, with an optional reference. **HTTP:** `POST /api/iam/billing/markPaid` (requires a credential) · **Browser client:** `client.billing.markPaid()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/statements`), called on the account or the root tenant. * **Audited as:** `billing:statement-paid`. * **Errors:** `INVALID_TRANSITION` (409) for a draft, paid or void statement. ```ts title="Signature" iam.api.billing.markPaid( credential: CredentialInput, input: { tenantId: string; statementId: string; reference?: string }, ): Promise ``` ## markUncollectible [#markuncollectible] Writes a finalized invoice off as uncollectible; a later payment still settles it. **HTTP:** `POST /api/iam/billing/markUncollectible` (requires a credential) · **Browser client:** `client.billing.markUncollectible()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/statements`). * **Audited as:** `billing:statement-uncollectible`. * **Errors:** `INVALID_TRANSITION` (409) for an invoice that is not finalized. Uncollectible invoices get no payment reminders. ```ts title="Signature" iam.api.billing.markUncollectible( credential: CredentialInput, input: { tenantId: string; statementId: string }, ): Promise ``` ## mySpend [#myspend] Reports the caller's own spend: their usage and that of the agents they sponsor, with the budgets set on them. **HTTP:** `POST /api/iam/billing/mySpend` (requires a credential) · **Browser client:** `client.billing.mySpend()` * **Permission:** The caller's own session in their own tenant. * **Audited as:** not audited. * **Errors:** `ACCESS_DENIED` (403) for another tenant; `INVALID_INPUT` for a `groupBy` other than `meter`, `day`, `agent`, `tenant` or `tag:{name}`. ```ts title="Signature" iam.api.billing.mySpend( credential: CredentialInput, input: { tenantId: string; period?: string; groupBy?: SpendGroupBy }, ): Promise ``` ## previewStatement [#previewstatement] Builds the statement a billing account would receive for a month (the current one so far by default), without issuing it. **HTTP:** `POST /api/iam/billing/previewStatement` (requires a credential) · **Browser client:** `client.billing.previewStatement()` * **Permission:** `iam:billing:read` on `iam/billing/statements`. * **Audited as:** `iam:billing:read`. * **Errors:** `INVALID_INPUT` when the tenant is not a billing account. ```ts title="Signature" iam.api.billing.previewStatement( credential: CredentialInput, input: { tenantId: string; period?: string }, ): Promise ``` ## quote [#quote] Prices a quantity of a meter as a month total for the tenant's billing account. **HTTP:** `POST /api/iam/billing/quote` (requires a credential) · **Browser client:** `client.billing.quote()` * **Permission:** `iam:billing:read` on `iam/billing/meters/{key}`. * **Audited as:** `iam:billing:read`. * **Errors:** `NOT_FOUND` for an unknown meter; `INVALID_INPUT` for a reported meter or a negative quantity. Tiers and free units apply as they would to the account's month total; `unpriced` is true without a price. ```ts title="Signature" iam.api.billing.quote( credential: CredentialInput, input: { tenantId: string; meter: string; quantity: number; period?: string }, ): Promise<| { price: PriceView; meter: string; quantity: number; period: string; currency: string; amountMicros: number; amount: number; } | { unpriced: true; meter: string; quantity: number; period: string; currency: string; amountMicros: number; amount: number; }> ``` ## record [#record] Records usage of a meter in the tenant, attributed to an identity, their teams and department, with optional tags. **HTTP:** `POST /api/iam/billing/record` (requires a credential) · **Browser client:** `client.billing.record()` * **Permission:** `iam:billing:record` on `iam/billing/meters/{key}`. * **Audited as:** `iam:billing:record`. * **Errors:** `NOT_FOUND` for an unknown meter or an identity outside the tenant and its ancestors; `METER_ARCHIVED` (409); `BILLING_PERIOD_CLOSED` (409) for a month already invoiced; `SPEND_LIMIT_REACHED` (402) with `enforceBudgets` under a spent enforced budget; `CONFLICT` (409) for an idempotency key used on another meter; `INVALID_INPUT` for a negative quantity, `cost` on a rate-card meter or none on a reported one, or `occurredAt` more than five minutes ahead or a year back. `idempotencyKey` makes retries safe: a repeat returns the first receipt with `duplicate: true`. `teamId` attributes the usage to one team instead of the person's own. Server code records without an audit event per call through `iam.billing.record`, the usual choice for metering. ```ts await client.billing.record({ tenantId, meter: 'api-calls', quantity: 1, identityId, tags: { endpoint: 'search' }, idempotencyKey: requestId, }); ``` ```ts title="Signature" iam.api.billing.record( credential: CredentialInput, input: UsageInput & { enforceBudgets?: boolean }, ): Promise ``` ## recordMany [#recordmany] Records up to 100 usage events in one transaction, all or nothing. **HTTP:** `POST /api/iam/billing/recordMany` (requires a credential) · **Browser client:** `client.billing.recordMany()` * **Permission:** `iam:billing:record` on `iam/billing` in the call's tenant. * **Audited as:** `iam:billing:record`. * **Errors:** as `record`; `INVALID_INPUT` for an event outside the tenant's subtree or more than 100 events. ```ts title="Signature" iam.api.billing.recordMany( credential: CredentialInput, input: { tenantId: string; events: Omit & { tenantId?: string }[]; }, ): Promise<{ receipts: UsageReceipt[] }> ``` ## recordPayment [#recordpayment] Records a payment against a finalized or uncollectible invoice. **HTTP:** `POST /api/iam/billing/recordPayment` (requires a credential) · **Browser client:** `client.billing.recordPayment()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/statements`), called on the account or the root tenant. * **Audited as:** `billing:payment`. * **Errors:** `INVALID_TRANSITION` (409) for a draft, paid or void invoice; `INVALID_INPUT` for an amount of 0 or a `receivedAt` in the future. `amount` (currency units) defaults to the amount due; `method` (default `manual`) and `reference` describe it. A partial payment leaves the rest due; once payments and credit notes cover the invoice it is `paid`. A payment above the amount due keeps the excess as account credit (`payment.overpaymentMicros`, `payment.creditId`). Payment processors report payments through `iam.billing.recordPayment({ statementId | number, amount, idempotencyKey })` instead, where the idempotency key makes webhook redelivery safe. ```ts await iam.api.billing.recordPayment(root, { tenantId: acmeId, statementId, amount: 300, method: 'bank_transfer', reference: 'wire-88213', }); ``` ```ts title="Signature" iam.api.billing.recordPayment( credential: CredentialInput, input: { tenantId: string; statementId: string; amount?: number; method?: string; reference?: string; receivedAt?: number; }, ): Promise<{ statement: StatementSummary; payment: InvoicePayment }> ``` ## redeemCoupon [#redeemcoupon] Redeems a coupon code for the billing account. **HTTP:** `POST /api/iam/billing/redeemCoupon` (requires a credential) · **Browser client:** `client.billing.redeemCoupon()` * **Permission:** `iam:billing:manage` on `iam/billing/discounts` in the account. * **Audited as:** `billing:coupon-redeem`. * **Errors:** `CONFLICT` (409) when the account already redeemed the coupon; `NOT_FOUND` for a code that is unknown, inactive, past `redeemBy` or out of redemptions (all answer the same); `INVALID_INPUT` when the tenant is not a billing account. The resulting discount applies to the account's invoices after the contract discount, in the order codes were redeemed. Voiding an invoice gives a one-off discount back. ```ts title="Signature" iam.api.billing.redeemCoupon( credential: CredentialInput, input: { tenantId: string; code: string }, ): Promise ``` ## removeDiscount [#removediscount] Ends a billing account's discount now. **HTTP:** `POST /api/iam/billing/removeDiscount` (requires a credential) · **Browser client:** `client.billing.removeDiscount()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/discounts`), called on the account. * **Audited as:** `billing:discount-remove`. * **Errors:** `INVALID_TRANSITION` (409) for a discount that has already ended. ```ts title="Signature" iam.api.billing.removeDiscount( credential: CredentialInput, input: { tenantId: string; discountId: string }, ): Promise ``` ## renderInvoice [#renderinvoice] Returns an invoice as a standalone HTML page to print or save as PDF. **HTTP:** `POST /api/iam/billing/renderInvoice` (requires a credential) · **Browser client:** `client.billing.renderInvoice()` * **Permission:** `iam:billing:read` on `iam/billing/statements`. * **Audited as:** `iam:billing:read`. * **Errors:** `NOT_FOUND` for an invoice outside the tenant's subtree. The result is `{ filename, contentType, body }`. The page names the issuer (the `billing.issuer` option: name, address, tax ID, contact, and `paymentInstructions` under the totals), the bill-to details, every line with its tier sub-lines, service period and proration, then discounts, coupons, credit, tax, payments, credit notes and the amount due. It has no scripts and only inline styles, so it can be served with a strict content security policy. ```ts title="Signature" iam.api.billing.renderInvoice( credential: CredentialInput, input: { tenantId: string; statementId: string }, ): Promise ``` ## resumeSubscription [#resumesubscription] Undoes a cancellation at the end of the month before it takes effect. **HTTP:** `POST /api/iam/billing/resumeSubscription` (requires a credential) · **Browser client:** `client.billing.resumeSubscription()` * **Permission:** `iam:billing:manage` on `iam/billing/subscriptions` (self-serve plans), or a root administrator. * **Audited as:** `billing:subscription-resume`. * **Errors:** `INVALID_TRANSITION` (409) for a subscription that is not set to cancel, or has ended. ```ts title="Signature" iam.api.billing.resumeSubscription( credential: CredentialInput, input: { tenantId: string; subscriptionId: string }, ): Promise ``` ## revokeCredit [#revokecredit] Withdraws what is left of a credit. **HTTP:** `POST /api/iam/billing/revokeCredit` (requires a credential) · **Browser client:** `client.billing.revokeCredit()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/credits`). * **Audited as:** `billing:credit-revoke`. * **Errors:** `INVALID_TRANSITION` (409) for a credit already revoked. ```ts title="Signature" iam.api.billing.revokeCredit( credential: CredentialInput, input: { tenantId: string; creditId: string }, ): Promise ``` ## setPrice [#setprice] Sets or removes a rate-card price for a meter the tenant defines: its list price, or a negotiated price for a tenant below it. **HTTP:** `POST /api/iam/billing/setPrice` (requires a credential) · **Browser client:** `client.billing.setPrice()` * **Permission:** `iam:billing:manage` on `iam/billing/meters/{key}` in the defining tenant. * **Audited as:** `billing:price`. * **Errors:** `NOT_FOUND` when the tenant does not define the meter; `INVALID_INPUT` for a reported meter, a target outside the subtree, malformed tiers, or `effectiveFrom` more than 12 months back; `BILLING_PERIOD_CLOSED` (409) when a month from `effectiveFrom` on is already invoiced for the tenants it reaches. `price` takes currency units: `per-unit` (`unitAmount`), `graduated` or `volume` (`tiers` of `upTo` and `unitAmount`, optional `flatAmount`, the last `upTo: null`), or `package` (`packageSize`, `packageAmount`), each with an optional `includedQuantity`. `effectiveFrom` (default this month) starts the price; `price: null` removes that entry. ```ts await iam.api.billing.setPrice(root, { tenantId: rootTenantId, meter: 'api-calls', targetTenantId: acmeId, price: { model: 'per-unit', unitAmount: 0.0003 }, note: 'Enterprise agreement', }); ``` ```ts title="Signature" iam.api.billing.setPrice( credential: CredentialInput, input: { tenantId: string; meter: string; targetTenantId?: string; effectiveFrom?: string; price: Record | null; note?: string; }, ): Promise ``` ## setProfile [#setprofile] Creates or updates a billing profile: company, billing emails, tax ID, address, purchase order, cost center, payment terms. **HTTP:** `POST /api/iam/billing/setProfile` (requires a credential) · **Browser client:** `client.billing.setProfile()` * **Permission:** `iam:billing:manage` on `iam/billing/profile`; a new profile below an organization is created from an ancestor with `targetTenantId`. * **Audited as:** `billing:profile`. * **Errors:** `ACCESS_DENIED` (403) when a tenant below an organization creates its own; `INVALID_INPUT` on the root tenant or for more than 10 emails. A profile below an organization makes that tenant a billing account of its own, a decision for its parent; afterwards the tenant's own billing managers may keep it up to date. `null` clears a field. ```ts title="Signature" iam.api.billing.setProfile( credential: CredentialInput, input: { tenantId: string; targetTenantId?: string; companyName?: string | null; billingEmails?: string[]; taxId?: string | null; address?: string | null; purchaseOrder?: string | null; costCenter?: string | null; paymentTermsDays?: number | null; }, ): Promise ``` ## setTerms [#setterms] Sets a billing account's contract terms: a discount off the subtotal, a minimum monthly commitment, and the tax invoices add. **HTTP:** `POST /api/iam/billing/setTerms` (requires a credential) · **Browser client:** `client.billing.setTerms()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/terms`), called on the account. * **Audited as:** `billing:terms`. * **Errors:** `INVALID_INPUT` when the tenant is not a billing account, for a percentage outside 0 to 100, or a negative commitment. Invoices issued from then on take the subtotal of their lines, subtract `discountPercent`, add the shortfall below `minimumCommitment` (currency units per month, monthly invoices only) as a true-up, subtract coupons and credit, and add `taxRatePercent` (labelled `taxLabel`, `Tax` by default) on the rest. `null` clears a term. Spend reports stay at rate-card prices. ```ts await iam.api.billing.setTerms(root, { tenantId: acmeId, discountPercent: 10, minimumCommitment: 1000, taxRatePercent: 20, taxLabel: 'VAT', }); ``` ```ts title="Signature" iam.api.billing.setTerms( credential: CredentialInput, input: { tenantId: string; discountPercent?: number | null; minimumCommitment?: number | null; taxRatePercent?: number | null; taxLabel?: string | null; }, ): Promise ``` ## spend [#spend] Reports the spend of the tenant and every tenant below it for a month, grouped and filtered. **HTTP:** `POST /api/iam/billing/spend` (requires a credential) · **Browser client:** `client.billing.spend()` * **Permission:** `iam:billing:read` on `iam/billing`. * **Audited as:** `iam:billing:read`. * **Errors:** `NOT_FOUND` for a team, department or sub-tenant outside the scope; `INVALID_INPUT` for an unknown `groupBy` or a malformed month. `groupBy` is `meter` (default), `identity`, `agent`, `team`, `department`, `tenant`, `day` or `tag:{name}`. Filters: `meter`, `identityId`, `teamId`, `departmentId`, `subTenantId`, `rollUp: false` (no sub-teams or sub-departments) and `billableOnly` (leave out chargeback meters). Each row has `costMicros`, `amount`, `share` (percent) and `quantities` per meter; the current month carries a linear `forecast`. For showback, `shareUnattributed: true` (grouped by `identity`, `agent`, `team` or `department`) spreads the unattributed row over the other groups by their share of spend, reported as `sharedMicros` per row and for the report. ```ts const report = await iam.api.billing.spend(admin, { tenantId, groupBy: 'team' }); ``` ```ts title="Signature" iam.api.billing.spend( credential: CredentialInput, input: { tenantId: string; period?: string; groupBy?: SpendGroupBy } & SpendFilters, ): Promise ``` ## subscribe [#subscribe] Subscribes the billing account to a plan (id or key). **HTTP:** `POST /api/iam/billing/subscribe` (requires a credential) · **Browser client:** `client.billing.subscribe()` * **Permission:** `iam:billing:manage` on `iam/billing/subscriptions`: self-serve plans for the account's billing managers, any plan for root administrators. * **Audited as:** `billing:subscription`. * **Errors:** `ACCESS_DENIED` (403) for a plan that is not self-serve, or `trialDays` from anyone but a root administrator; `CONFLICT` (409) when the account already subscribes to the plan; `INVALID_TRANSITION` (409) for an archived plan; `INVALID_INPUT` when the tenant is not a billing account. `seats` defaults to 1. Outside a trial the rest of this month's advance fees and seats are invoiced at once (the result's `invoice`, `billingReason: 'subscription'`); after that each monthly invoice bills the month ahead. A trial bills nothing until it ends; the part of the month after it is billed on that month's invoice. ```ts const { subscription, invoice } = await iam.api.billing.subscribe(orgAdmin, { tenantId: acmeId, plan: 'team', seats: 8, }); ``` ```ts title="Signature" iam.api.billing.subscribe( credential: CredentialInput, input: { tenantId: string; plan: string; seats?: number; trialDays?: number }, ): Promise<{ subscription: SubscriptionView; invoice?: StatementSummary }> ``` ## teamSpend [#teamspend] Reports a team's spend, with the teams below it, grouped by `identity` by default. **HTTP:** `POST /api/iam/billing/teamSpend` (requires a credential) · **Browser client:** `client.billing.teamSpend()` * **Permission:** The team's maintainers (and those of teams above it), or `iam:billing:read`. * **Audited as:** `iam:billing:read`, with `metadata.via` `team-maintainer` or `permission`. * **Errors:** `ACCESS_DENIED` (403) for anyone else; `NOT_FOUND` for a team outside the tenant. ```ts title="Signature" iam.api.billing.teamSpend( credential: CredentialInput, input: { tenantId: string; teamId: string; period?: string; groupBy?: SpendGroupBy }, ): Promise ``` ## trend [#trend] Returns monthly totals for the last `months` months (1 to 24, default 6), with the filters of `spend`. **HTTP:** `POST /api/iam/billing/trend` (requires a credential) · **Browser client:** `client.billing.trend()` * **Permission:** `iam:billing:read` on `iam/billing`. * **Audited as:** `iam:billing:read`. ```ts title="Signature" iam.api.billing.trend( credential: CredentialInput, input: { tenantId: string; months?: number } & SpendFilters, ): Promise ``` ## updateBudget [#updatebudget] Changes a budget's name, amount, period, meters, thresholds, alerts or enforcement; its subject stays. **HTTP:** `POST /api/iam/billing/updateBudget` (requires a credential) · **Browser client:** `client.billing.updateBudget()` * **Permission:** `iam:billing:manage` on `iam/billing/budgets`. * **Audited as:** `billing:budget-update`. * **Errors:** `CONFLICT` (409) for a name another budget uses; `NOT_FOUND` for a budget of another tenant. `meters: null` counts every meter again. ```ts title="Signature" iam.api.billing.updateBudget( credential: CredentialInput, input: { tenantId: string; budgetId: string } & Partial< Omit > & { meters?: string[] | null }, ): Promise ``` ## updateMeter [#updatemeter] Renames a meter, changes its unit or description, or archives it. **HTTP:** `POST /api/iam/billing/updateMeter` (requires a credential) · **Browser client:** `client.billing.updateMeter()` * **Permission:** `iam:billing:manage` on `iam/billing/meters/{key}`. * **Audited as:** `billing:meter-update`. * **Errors:** `NOT_FOUND` when the tenant does not define the key; `INVALID_INPUT` when changing `aggregation` or `pricing`. An archived meter refuses new usage (`METER_ARCHIVED`) and keeps its history; `archived: false` restores it. ```ts title="Signature" iam.api.billing.updateMeter( credential: CredentialInput, input: { tenantId: string; key: string; name?: string; unit?: string; description?: string | null; archived?: boolean; }, ): Promise ``` ## updatePlan [#updateplan] Changes a plan, by id or key: name, items, description, trial, self-serve, archived. **HTTP:** `POST /api/iam/billing/updatePlan` (requires a credential) · **Browser client:** `client.billing.updatePlan()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/plans`), called on the root tenant. * **Audited as:** `billing:plan-update`. * **Errors:** `NOT_FOUND` for an unknown plan; `INVALID_INPUT` as for `createPlan`. `null` clears `description` or `trialDays`. An archived plan takes no new subscriptions. Changes apply to invoices drawn up afterwards. ```ts title="Signature" iam.api.billing.updatePlan( credential: CredentialInput, input: { tenantId: string; plan: string; name?: string; items?: unknown[]; description?: string | null; trialDays?: number | null; selfServe?: boolean; archived?: boolean; }, ): Promise ``` ## updateSubscription [#updatesubscription] Changes a subscription's seats. **HTTP:** `POST /api/iam/billing/updateSubscription` (requires a credential) · **Browser client:** `client.billing.updateSubscription()` * **Permission:** `iam:billing:manage` on `iam/billing/subscriptions` (self-serve plans), or a root administrator. * **Audited as:** `billing:subscription-update`. * **Errors:** `INVALID_TRANSITION` (409) for an ended subscription; `INVALID_INPUT` for seats outside 0 to 1000000. Seats billed in advance for this month are prorated as invoice items on the next invoice (`invoiceItems`): added seats for the rest of the month, credit for removed ones. Arrears seats follow the seat history on their own. ```ts title="Signature" iam.api.billing.updateSubscription( credential: CredentialInput, input: { tenantId: string; subscriptionId: string; seats: number }, ): Promise<{ subscription: SubscriptionView; invoiceItems: InvoiceItemView[] }> ``` ## voidStatement [#voidstatement] Voids a finalized statement: its credit, invoice items, coupons and advance-billed months come back and the month reopens. **HTTP:** `POST /api/iam/billing/voidStatement` (requires a credential) · **Browser client:** `client.billing.voidStatement()` * **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/statements`), called on the account or the root tenant. * **Audited as:** `billing:statement-void`. * **Errors:** `INVALID_TRANSITION` (409) for a statement already void, a draft, or one with payments or credit notes (issue a credit note instead). Fix the usage, then close the month again: the new statement gets a new number. Credit the invoice created from a negative balance is revoked. ```ts title="Signature" iam.api.billing.voidStatement( credential: CredentialInput, input: { tenantId: string; statementId: string; reason: string }, ): Promise ``` # bindings (/docs/reference/api/bindings) > Bindings give a role to a person, service account, or group, and control when that role actually applies. Bindings give a role to a person, service account, or group, and control when that role actually applies. A role grants nothing until it is bound: a binding links one role to one subject, and the subject holds the role for as long as the binding says. A binding can be standing (always on), temporary, future-dated, limited to recurring hours, or eligible, which means the subject may take the role just in time, for a bounded period, with a reason, MFA, or a second person's approval. This group creates and changes bindings, runs that activation and approval flow, and answers "who holds what". ## Why a binding needs grant authority [#why-a-binding-needs-grant-authority] Every binding is created under a [grant authority](/docs/guides/authorization/roles#grant-authorities): the delegated right to hand out access, with a ceiling on what it may ever grant. Pass `authorityId` to pick one of your own authorities; otherwise the server uses your root-issued authority (root administrators) or your first active delegated authority. A caller with no active authority gets `GRANT_AUTHORITY_REQUIRED`. The binding stores the authority's id, and that matters in three ways: * **Ceiling.** Whatever the role says, the binding grants no more than the ceilings of its authority chain allow. They are applied as boundaries every time a request is evaluated. * **Revocation.** When the authority, or any authority above it, is [revoked](/docs/reference/api/authorities#revoke), the binding grants nothing from the next request on. * **Ownership.** Only the holder of that authority, or root, may update or delete the binding or revoke activations of it. A junior administrator cannot undo a senior administrator's grants. The permission and the authority answer different questions. `iam:bindings:create` on `iam/{roleId}` decides which roles you may bind; the authority caps what those bindings can reach. ## When a binding applies [#when-a-binding-applies] A binding without dates or rules is standing: it applies until someone removes it. Four options narrow it without changing the role: | Option | Effect | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `expiresAt` | Epoch milliseconds, in the future and at most ten years ahead. The binding stops granting at that instant, is left out of listings unless you pass `includeExpired: true`, and is deleted with its activations by the purge worker ([`purgeDeleted`](/docs/reference/api#purgedeleted)). | | `startsAt` | Epoch milliseconds, not in the past and at most ten years ahead, before `expiresAt`. The binding is listed with its start but grants nothing until then. Separation-of-duties rules already count it. | | `window` | `{ from, to, timeZone, days? }`: `HH:MM` times in an IANA time zone, and weekdays 0 (Sunday) to 6 (every day by default). Outside the window the binding grants nothing. A window whose end is not after its start wraps past midnight and counts as the day it starts on. | | `eligible` | The binding grants nothing until its subject activates it, for a bounded time. | A group binding reaches every live member of the group, so membership changes grant and remove the role too. See [temporary access](/docs/guides/authorization/temporary-access) for dates and windows. ## Just-in-time activation [#just-in-time-activation] An eligible binding records that its subject *may* hold a role. The subject activates it with `activate` when they need the role, and it lapses on its own. That keeps administrator and production roles out of everyday sessions while every elevation leaves a record with a reason. The full walkthrough is in [just-in-time elevation](/docs/guides/privileged-access/elevation). Each eligible binding carries its activation rules: | Setting | Effect | | ---------------------- | -------------------------------------------------------------------------------------------------------------------- | | `maxActivationMs` | The longest activation, from one minute to seven days. Defaults to one hour. | | `requireJustification` | The activation must state a reason (at most 2048 characters). It is stored on the activation and in the audit trail. | | `requireMfa` | Only a session that completed MFA may activate. | | `requireApproval` | Activation becomes a request that an approver grants or denies. | | `approverGroupId` | Only live members of this group (or root) may decide, and they are emailed each request. | | `managerApproval` | The requester's manager (`managerId`) may decide as well, and is emailed each request. | Passing any of these for a binding that is not eligible fails with `INVALID_INPUT`; explicit `false` flags are accepted, so forms can send every checkbox. The tenant's [access policy](/docs/reference/api/tenants#setaccesspolicy) tightens every eligible binding at once: a rule applies when either the binding or the policy sets it, the maximum length is the smaller of the two, and the policy's `approvalLifetimeMs` (one day by default) is how long a request waits for a decision. There is one activation record per binding and person. It starts `active` (or `pending` when approval is required), and it ends when it reaches `expiresAt`, when its holder calls `deactivate`, when an administrator calls `revokeActivation`, or when the person leaves the group, the binding or role is deleted, or eligibility is turned off. An activation belongs to the person, not the session: signing out does not end it. An access window on the binding still applies while it is active. Who may elevate and who may approve are ordinary permissions: `iam:bindings:activate` and `iam:bindings:approve` on `iam/{roleId}`. The usual setup is a Member role holding `iam:bindings:activate`, bound to a group everyone belongs to, and an Approver role bound to the approver group. | Method | What it does | Access | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`activate`](#activate) | Activates an eligible binding for yourself so you hold its role for a limited time, or records an approval request when the binding requires one. | Credential | | [`approveActivation`](#approveactivation) | Grants a pending activation request, so the requester holds the role from now until the approved duration ends. | Credential | | [`create`](#create) | Gives a role to a person, service account, or group under your grant authority, optionally temporary, future-dated, limited to a recurring window, or eligible for just-in-time activation. | Credential | | [`deactivate`](#deactivate) | Ends your own activation early, or withdraws your own pending request. | Credential | | [`delete`](#delete) | Removes a binding and every activation of it, so its subject stops holding the role at the next request. | Credential | | [`denyActivation`](#denyactivation) | Refuses a pending activation request, optionally with a note for the requester. | Credential | | [`list`](#list) | Lists the tenant's bindings, filtered by role, subject, eligibility, or upcoming expiry. | Credential | | [`listActivations`](#listactivations) | Lists activations and activation requests, newest first, filtered by binding, person, role, or status. | Credential | | [`listApprovals`](#listapprovals) | Lists the pending activation requests you may decide on, oldest first, with the role and the requester. | Credential | | [`listMine`](#listmine) | Lists your own bindings, direct and through groups, with any live activation or waiting request, so you can see what you may elevate to. | Credential | | [`revokeActivation`](#revokeactivation) | Ends someone else's activation or pending request immediately, for incident response. | Credential | | [`update`](#update) | Changes a binding's start, expiry, access window, or eligibility settings in place. | Credential | ## activate [#activate] Activates an eligible binding for yourself so you hold its role for a limited time, or records an approval request when the binding requires one. **HTTP:** `POST /api/iam/bindings/activate` (requires a credential) · **Browser client:** `client.bindings.activate()` * **Permission:** `iam:bindings:activate` on the role (`iam/{roleId}`), from your own ordinary session of the tenant. The binding must apply to you, directly or through a group you are a live member of. * **Audited as:** `iam:bindings:activate`, plus `binding:activate` (with `activationId`, `roleId`, `expiresAt`, and the justification) or, when approval is required, `binding:activation-requested`. * **Errors:** `INVALID_TRANSITION` when the binding is not eligible, has not started, or has expired, or when it requires approval from named approvers and none of them is active (an empty approver group and no active manager); `ACCESS_DENIED` when the binding does not apply to you; `INVALID_INPUT` from an assumed-role session or a session of another tenant, without a required justification, or for a `durationMs` outside one minute to the effective maximum; `MFA_REQUIRED` when MFA is required and your session did not complete it; `IMPERSONATION_RESTRICTED` from a "view as" session; `CONFLICT` while you already hold a live activation or a waiting request for this binding; `INVARIANT_VIOLATION`. `durationMs` defaults to the effective maximum: the binding's `maxActivationMs`, capped by the tenant's access policy. Without approval, the result has `status: 'active'` and `active: true`, and the role applies from the next request until `expiresAt`. With approval, the result has `status: 'pending'`; it lapses after the tenant's `approvalLifetimeMs` (24 hours by default) unless someone decides. When the deployment sends email, the approver group's live members and, with `managerApproval`, your manager receive an `activation-request`. An earlier activation that ended or was denied is replaced, so you can ask again after a refusal. ```ts const activation = await iam.api.bindings.activate(credential, { tenantId, bindingId, durationMs: 30 * 60 * 1000, justification: 'INC-4211: restart the payments worker', }); if (activation.status === 'pending') { // Waiting for an approver; bindings.listMine shows it as pendingActivation. } ``` ```ts title="Signature" iam.api.bindings.activate( credential: CredentialInput, input: { tenantId: string; bindingId: string; durationMs?: number; justification?: string; }, ): Promise<{ status: 'active' | 'pending' | 'denied'; active: boolean; bindingId: string; identityId: string; roleId: string; activatedAt: number; expiresAt: number; justification?: string; sessionId: string; requestedDurationMs?: number; decidedBy?: string; decidedAt?: number; note?: string; id: string; tenantId: string; }> ``` ## approveActivation [#approveactivation] Grants a pending activation request, so the requester holds the role from now until the approved duration ends. **HTTP:** `POST /api/iam/bindings/approveActivation` (requires a credential) · **Browser client:** `client.bindings.approveActivation()` * **Permission:** `iam:bindings:approve` on the role (`iam/{roleId}`). When the binding names approvers, you must also be a live member of its approver group, the requester's manager (with `managerApproval`), or root. * **Audited as:** `iam:bindings:approve`, plus `binding:activation-approved` with the new `expiresAt` and your note. * **Errors:** `INVALID_TRANSITION` (409) when the request is no longer waiting (decided, withdrawn, or lapsed) or its binding is no longer eligible and live; `INVALID_INPUT` when you decide your own request, for a `durationMs` outside one minute to the effective maximum, or for a note over 2048 characters; `ACCESS_DENIED` when you are not one of the designated approvers; `IMPERSONATION_RESTRICTED` from a "view as" session; `NOT_FOUND`; `INVARIANT_VIOLATION`. Without `durationMs`, the requester gets the duration they asked for, capped at the binding's effective maximum as it stands now (the tenant policy may have tightened since the request). Your `durationMs` replaces the requested one, for example to grant a shorter window than asked. The activation starts when you approve, not when it was requested. The requester is emailed `activation-decided` when the deployment sends email. Because nobody decides their own request and an impersonating administrator cannot decide in someone's name, approval gives you two-person control. ```ts await iam.api.bindings.approveActivation(credential, { tenantId, activationId, durationMs: 45 * 60 * 1000, note: 'Approved for the change window', }); ``` ```ts title="Signature" iam.api.bindings.approveActivation( credential: CredentialInput, input: { tenantId: string; activationId: string; note?: string; durationMs?: number }, ): Promise<{ status: 'active' | 'pending' | 'denied'; active: boolean; bindingId: string; identityId: string; roleId: string; activatedAt: number; expiresAt: number; justification?: string; sessionId: string; requestedDurationMs?: number; decidedBy?: string; decidedAt?: number; note?: string; id: string; tenantId: string; }> ``` ## create [#create] Gives a role to a person, service account, or group under your grant authority, optionally temporary, future-dated, limited to a recurring window, or eligible for just-in-time activation. **HTTP:** `POST /api/iam/bindings/create` (requires a credential) · **Browser client:** `client.bindings.create()` * **Permission:** `iam:bindings:create` on the role (`iam/{roleId}`), plus an active grant authority. * **Audited as:** `iam:bindings:create`. * **Errors:** `PROTECTED_RESOURCE` for the protected Owner role; `NOT_FOUND` when the role, group, approver group, or identity is not in this tenant, or the identity is deleted; `GRANT_AUTHORITY_REQUIRED` when you hold no active grant authority; `ACCESS_DENIED` when `authorityId` names an authority that is not yours or is revoked; `INVALID_INPUT` for an invalid subject type, date, or window, a `startsAt` that is not before `expiresAt`, or activation settings on a binding that is not eligible; `CONFLICT` when the subject already has a binding of this role under the same authority; `SOD_CONFLICT` when the binding would give someone a combination of roles a [separation-of-duties rule](/docs/guides/authorization/separation-of-duties) forbids; `INVARIANT_VIOLATION` when it would newly break an enforced [access invariant](/docs/reference/api/invariants). Bind roles to groups where you can: people then gain and lose the role as they join and leave, without anyone editing bindings. The Owner role is never bound this way; ownership changes go through [`identities.setOwner`](/docs/reference/api/identities#setowner). An expired binding that the purge worker has not removed yet still counts for `CONFLICT`; extend it with `update` instead of creating a new one. ```ts // A contractor gets support access on weekdays, starting next week, for 90 days. const startsAt = Date.now() + 7 * 86_400_000; await iam.api.bindings.create(credential, { tenantId, roleId: support.id, subjectType: 'identity', subjectId: contractor.id, startsAt, expiresAt: startsAt + 90 * 86_400_000, window: { from: '09:00', to: '17:00', timeZone: 'Europe/Berlin', days: [1, 2, 3, 4, 5] }, }); // The on-call group may take the incident-responder role for up to two hours, with a reason and MFA. await iam.api.bindings.create(credential, { tenantId, roleId: responder.id, subjectType: 'group', subjectId: onCall.id, eligible: true, maxActivationMs: 2 * 60 * 60 * 1000, requireJustification: true, requireMfa: true, }); ``` ```ts title="Signature" iam.api.bindings.create( credential: CredentialInput, input: BindingInput, ): Promise ``` ## deactivate [#deactivate] Ends your own activation early, or withdraws your own pending request. **HTTP:** `POST /api/iam/bindings/deactivate` (requires a credential) · **Browser client:** `client.bindings.deactivate()` * **Permission:** `iam:bindings:activate` on the role (`iam/{roleId}`); only the person the activation belongs to. * **Audited as:** `iam:bindings:activate`, plus `binding:deactivate` (with `cancelled: true` for a withdrawn request). * **Errors:** `ACCESS_DENIED` when the activation belongs to someone else (administrators use `revokeActivation`); `NOT_FOUND`; `INVARIANT_VIOLATION`. Step down when the work is done early: the role stops applying at the next request. The record is deleted, so you can activate again later. ```ts title="Signature" iam.api.bindings.deactivate( credential: CredentialInput, input: { tenantId: string; activationId: string }, ): Promise<{ deactivated: boolean }> ``` ## delete [#delete] Removes a binding and every activation of it, so its subject stops holding the role at the next request. **HTTP:** `POST /api/iam/bindings/delete` (requires a credential) · **Browser client:** `client.bindings.delete()` * **Permission:** `iam:bindings:delete` on the binding (`iam/{bindingId}`), and the binding's own grant authority (or root). * **Audited as:** `iam:bindings:delete`. * **Errors:** `ACCESS_DENIED` when another administrator's authority issued the binding; `PROTECTED_RESOURCE` for an Owner binding; `NOT_FOUND` when the binding is not in this tenant; `INVARIANT_VIOLATION` when removing it would break an enforced invariant that expects someone to keep access. To take a group's role away from one person, remove them from the group instead; deleting the group binding removes the role from every member. ```ts title="Signature" iam.api.bindings.delete( credential: CredentialInput, input: { tenantId: string; bindingId: string }, ): Promise<{ deleted: boolean }> ``` ## denyActivation [#denyactivation] Refuses a pending activation request, optionally with a note for the requester. **HTTP:** `POST /api/iam/bindings/denyActivation` (requires a credential) · **Browser client:** `client.bindings.denyActivation()` * **Permission:** `iam:bindings:approve` on the role (`iam/{roleId}`), and designated-approver status when the binding names approvers. * **Audited as:** `iam:bindings:approve`, plus `binding:activation-denied` with your note. * **Errors:** `INVALID_TRANSITION` (409) when the request is no longer waiting or its binding is no longer eligible and live; `INVALID_INPUT` when you decide your own request or the note is over 2048 characters; `ACCESS_DENIED` when you are not a designated approver; `IMPERSONATION_RESTRICTED`; `NOT_FOUND`. The request becomes `status: 'denied'` and grants nothing. It stays visible through `listActivations({ status: 'denied' })` until the purge worker removes it. The requester is emailed `activation-decided` with your note when the deployment sends email, and may ask again with `activate`. ```ts title="Signature" iam.api.bindings.denyActivation( credential: CredentialInput, input: { tenantId: string; activationId: string; note?: string }, ): Promise<{ status: 'active' | 'pending' | 'denied'; active: boolean; bindingId: string; identityId: string; roleId: string; activatedAt: number; expiresAt: number; justification?: string; sessionId: string; requestedDurationMs?: number; decidedBy?: string; decidedAt?: number; note?: string; id: string; tenantId: string; }> ``` ## list [#list] Lists the tenant's bindings, filtered by role, subject, eligibility, or upcoming expiry. **HTTP:** `POST /api/iam/bindings/list` (requires a credential) · **Browser client:** `client.bindings.list()` * **Permission:** `iam:bindings:read` on the role (`iam/{roleId}`) when you filter by `roleId`, otherwise on the subject (`iam/{subjectId}`) when you filter by `subjectId`, otherwise on the tenant. * **Audited as:** `iam:bindings:read`. * **Errors:** `INVALID_INPUT` for an unknown `subjectType`, a non-boolean `eligible`, or an invalid `expiresBefore`. Expired bindings are left out unless you pass `includeExpired: true`; future-dated ones are included. `eligible: true` keeps only eligible bindings and `eligible: false` only standing ones, which is how you audit standing privileged access. `expiresBefore` keeps temporary bindings that end at or before that time, for "what ends this month?" reports. Group bindings are returned as stored, not expanded to members: use [`identities.listBindings`](/docs/reference/api/identities#listbindings) for one person's effective roles and [`roles.listBindings`](/docs/reference/api/roles#listbindings) for holders with names. ```ts const endingSoon = await iam.api.bindings.list(credential, { tenantId, expiresBefore: Date.now() + 14 * 86_400_000, }); ``` ```ts title="Signature" iam.api.bindings.list( credential: CredentialInput, input: { tenantId: string; roleId?: string; subjectType?: 'identity' | 'group'; subjectId?: string; includeExpired?: boolean; eligible?: boolean; expiresBefore?: number; }, ): Promise ``` ## listActivations [#listactivations] Lists activations and activation requests, newest first, filtered by binding, person, role, or status. **HTTP:** `POST /api/iam/bindings/listActivations` (requires a credential) · **Browser client:** `client.bindings.listActivations()` * **Permission:** `iam:bindings:read` on the first of `bindingId`, `identityId`, or `roleId` you filter by (`iam/{id}`), otherwise on the tenant. * **Audited as:** `iam:bindings:read`. * **Errors:** `INVALID_INPUT` for a `status` other than `pending`, `active`, or `denied`. Without `status`, the result is who is elevated right now: live activations only. `status: 'pending'` lists open requests, `'active'` activations, and `'denied'` refusals (all of them, until the purge worker removes them). `includeExpired: true` adds records that have ended or lapsed but are not yet purged. Each record carries `status` and `active` (whether it grants at this moment). ```ts title="Signature" iam.api.bindings.listActivations( credential: CredentialInput, input: { tenantId: string; bindingId?: string; identityId?: string; roleId?: string; includeExpired?: boolean; status?: 'pending' | 'active' | 'denied'; }, ): Promise<{ status: 'active' | 'pending' | 'denied'; active: boolean; bindingId: string; identityId: string; roleId: string; activatedAt: number; expiresAt: number; justification?: string; sessionId: string; requestedDurationMs?: number; decidedBy?: string; decidedAt?: number; note?: string; id: string; tenantId: string; }[]> ``` ## listApprovals [#listapprovals] Lists the pending activation requests you may decide on, oldest first, with the role and the requester. **HTTP:** `POST /api/iam/bindings/listApprovals` (requires a credential) · **Browser client:** `client.bindings.listApprovals()` * **Permission:** `iam:bindings:approve` on the tenant. A request is included only when you also hold `iam:bindings:approve` on its role and are a designated approver where the binding names any. * **Audited as:** `iam:bindings:approve`. It never includes your own requests or requests that have lapsed. Use it to build an approver's inbox; each entry carries `role` (id and name) and `requester` (id, name, and email). ```ts title="Signature" iam.api.bindings.listApprovals( credential: CredentialInput, input: { tenantId: string }, ): Promise<({ status: 'active' | 'pending' | 'denied'; active: boolean; bindingId: string; identityId: string; roleId: string; activatedAt: number; expiresAt: number; justification?: string; sessionId: string; requestedDurationMs?: number; decidedBy?: string; decidedAt?: number; note?: string; id: string; tenantId: string; } & { role?: { id: string; name: string }; requester?: { id: string; name: string; email?: string }; })[]> ``` ## listMine [#listmine] Lists your own bindings, direct and through groups, with any live activation or waiting request, so you can see what you may elevate to. **HTTP:** `POST /api/iam/bindings/listMine` (requires a credential) · **Browser client:** `client.bindings.listMine()` * **Permission:** `iam:bindings:activate` on the tenant, from an ordinary session of the tenant. * **Audited as:** `iam:bindings:activate`. * **Errors:** `INVALID_INPUT` from an assumed-role session or a session of another tenant. It needs no `iam:bindings:read`, so members see their own access without seeing everyone else's. Each entry is a binding with `via` (`'identity'`, or `{ groupId }` for a group binding) and its `role`, plus `activation` (`id`, `activatedAt`, `expiresAt`) while one is live, `pendingActivation` (`id`, `requestedAt`, `expiresAt`) while a request waits, and `inWindow` for bindings with an access window. Standing and eligible bindings are both listed; future-dated ones appear with their `startsAt`, and expired ones are left out. ```ts title="Signature" iam.api.bindings.listMine( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## revokeActivation [#revokeactivation] Ends someone else's activation or pending request immediately, for incident response. **HTTP:** `POST /api/iam/bindings/revokeActivation` (requires a credential) · **Browser client:** `client.bindings.revokeActivation()` * **Permission:** `iam:bindings:delete` on the binding (`iam/{bindingId}`), and the binding's own grant authority (or root), like deleting the binding. * **Audited as:** `iam:bindings:delete`, plus `binding:deactivate` with `revoked: true` and the holder's `identityId`. * **Errors:** `ACCESS_DENIED` when another administrator's authority issued the binding; `NOT_FOUND`; `INVARIANT_VIOLATION`. Revoking ends the elevation, not the entitlement: the binding stays eligible and the person can activate it again. To stop that, delete the binding or remove the person from its group. Revoking does not end the person's sessions; use [`identities.revokeSessions`](/docs/reference/api/identities#revokesessions) for that. ```ts title="Signature" iam.api.bindings.revokeActivation( credential: CredentialInput, input: { tenantId: string; activationId: string }, ): Promise<{ deactivated: boolean }> ``` ## update [#update] Changes a binding's start, expiry, access window, or eligibility settings in place. **HTTP:** `POST /api/iam/bindings/update` (requires a credential) · **Browser client:** `client.bindings.update()` * **Permission:** `iam:bindings:create` on the role (`iam/{roleId}`), and the binding's own grant authority (or root). * **Audited as:** `iam:bindings:create`. * **Errors:** `ACCESS_DENIED` ("Cannot mutate a higher authority binding") when another administrator's authority issued the binding; `PROTECTED_RESOURCE` for an Owner binding; `INVALID_INPUT` when nothing is given to change, or under the same date, window, and setting rules as `create`; `NOT_FOUND`; `SOD_CONFLICT` when extending an already expired binding would create a forbidden combination; `INVARIANT_VIOLATION`. Pass `null` to clear `startsAt`, `expiresAt`, `window`, or `approverGroupId`. The binding keeps its id and its authority. `eligible: false` turns an eligible binding into a standing one, drops its activation settings, and ends every activation and pending request of it; `eligible: true` does the reverse, so the subject stops holding the role until they activate it. Editing a binding that an [access package](/docs/guides/privileged-access/access-packages) created takes it over: revoking the package no longer removes it. ```ts // Extend a contractor's access by 30 days and drop the business-hours limit. await iam.api.bindings.update(credential, { tenantId, bindingId, expiresAt: Date.now() + 30 * 86_400_000, window: null, }); ``` ```ts title="Signature" iam.api.bindings.update( credential: CredentialInput, input: { tenantId: string; bindingId: string; startsAt?: number | null; expiresAt?: number | null; window?: AccessWindow | null; } & EligibilityInput, ): Promise ``` # certifications (/docs/reference/api/certifications) > Certification campaigns turn periodic access reviews into recorded decisions. Certification campaigns turn periodic access reviews into recorded decisions. A campaign snapshots the role bindings under review, reviewers keep or revoke each one, and closing the campaign removes the revoked bindings and records what happened to every item, which is the evidence auditors ask for. See the [certifications guide](/docs/guides/governance/certifications) for the full workflow. Campaign permissions are checked on the internal resource `iam/certifications/*` for tenant-wide calls (`create`, `list`) and on `iam/certifications/{campaignId}` for one campaign, so you can scope who manages or reviews which campaign. ## Reviewers and who may decide [#reviewers-and-who-may-decide] Every item is one binding as it stood when the campaign opened. Who decides it depends on the campaign's `reviewerMode`: * **`named`** (the default): the people in `reviewerIds` decide every item with `decide`. When `reviewerIds` is empty, anyone holding `iam:certifications:review` on the campaign may decide. * **`manager`**: each person's items are assigned to their manager (`Identity.managerId`) when that manager is an active, unexpired member of the tenant. Managers decide their items with `review`, which needs no certification permission, and find them with `listMine`. Items without an active manager, and items held by groups, fall back to the named reviewers (or to anyone holding `iam:certifications:review` when none are named). Through `decide`, a manager-assigned item may be decided only by that manager or by a holder of `iam:certifications:manage`. Nobody decides on their own access, directly or through a group they belong to (`SELF_REVIEW`). A decision can be changed until the campaign closes. [`roleMining.reviewRecommendations`](/docs/reference/api/role-mining#reviewrecommendations) suggests keep or revoke for every item from recorded usage and sign-in activity. ## What closing does [#what-closing-does] Closing applies the campaign in one transaction. Items decided `revoke`, and undecided items when the campaign's `undecided` is `revoke`, have their binding deleted under the closer's [grant authority](/docs/guides/authorization/roles#grant-authorities); everything else is kept. Each item records an `outcome`: * `kept`: the binding stays. * `revoked`: the binding was removed (with its just-in-time activations), audited as `iam:bindings:delete`. * `already-removed`: the binding to revoke was already gone, or no longer matched the item's role and subject. * `revocation-failed`: the closer may not remove it. A binding can be removed only by the administrator whose authority issued it or by a root administrator, so a binding someone else granted is left for them, with the reason in `outcomeDetail`. A binding an [access package](/docs/guides/privileged-access/access-packages) created is removed like any other: a manual assignment then reports `broken`, and an automatic assignment gets the binding back at the next reconcile while the person still matches the rule. To remove birthright access for good, change the rule. Campaigns created with `autoClose: true` and a `dueAt` are closed by the deployment job [`iam.closeOverdueCertifications()`](/docs/reference/api#closeoverduecertifications) (CLI `close-certifications`) once due, under the creator's authority, audited as `certification:auto-close` by `deployment-operator`. | Method | What it does | Access | | ----------------------- | --------------------------------------------------------------------------------------------------------------------- | ---------- | | [`close`](#close) | Closes an open campaign and applies it, removing the bindings reviewers revoked. | Credential | | [`create`](#create) | Opens a campaign over the tenant's current role bindings and notifies the reviewers. | Credential | | [`decide`](#decide) | Records keep or revoke decisions on up to 200 items of an open campaign. | Credential | | [`delete`](#delete) | Deletes a closed campaign and all its items. | Credential | | [`get`](#get) | Returns one campaign with its items and progress. | Credential | | [`list`](#list) | Lists the tenant's campaigns, newest first, each with its progress. | Credential | | [`listMine`](#listmine) | Lists the open campaigns that have items assigned to you as a manager, with only those items. | Credential | | [`remind`](#remind) | Emails every reviewer who still has undecided items a reminder with their pending count. | Credential | | [`review`](#review) | Records a manager's keep or revoke decisions on up to 200 items assigned to them, without a certification permission. | Credential | ## close [#close] Closes an open campaign and applies it, removing the bindings reviewers revoked. **HTTP:** `POST /api/iam/certifications/close` (requires a credential) · **Browser client:** `client.certifications.close()` * **Permission:** `iam:certifications:manage` on the campaign, with [recent authentication](/docs/guides/authentication/sessions#recent-authentication). * **Audited as:** `iam:certifications:manage`, plus one `iam:bindings:delete` per removed binding. * **Errors:** `RECENT_AUTH_REQUIRED` when your sign-in is not recent or the credential is temporary; `IMPERSONATION_RESTRICTED` from an impersonation session; `CONFLICT` when the campaign is already closed; `NOT_FOUND` when it is not in this tenant; `INVARIANT_VIOLATION` when the removals would break an enforced [invariant](/docs/guides/governance/change-safety). The result is the closed campaign with `outcomes`, the number of items per outcome. Close as the administrator who granted the bindings, or as root, to avoid `revocation-failed` items. The closed campaign keeps every decision and outcome until you delete it. ```ts const closed = await iam.api.certifications.close(credential, { tenantId, campaignId }); // closed.outcomes: { kept: 41, revoked: 6, 'already-removed': 1, 'revocation-failed': 0 } ``` ```ts title="Signature" iam.api.certifications.close( credential: CredentialInput, input: { tenantId: string; campaignId: string }, ): Promise }> ``` ## create [#create] Opens a campaign over the tenant's current role bindings and notifies the reviewers. **HTTP:** `POST /api/iam/certifications/create` (requires a credential) · **Browser client:** `client.certifications.create()` * **Permission:** `iam:certifications:manage` on `iam/certifications/*`. * **Audited as:** `iam:certifications:manage`. * **Errors:** `LIMIT_EXCEEDED` (409) when more than 5000 bindings would be reviewed; `INVALID_INPUT` for an empty name, a listed role that is unknown or protected, a `dueAt` that is not in the future, `autoClose` without `dueAt`, or an invalid `subjectType`, `reviewerMode`, or `undecided`; `NOT_FOUND` when a reviewer is not in this tenant or is deleted. The campaign covers the live bindings of every non-protected role, or only of `roleIds`, optionally only those held by people or by groups (`subjectType`). Bindings that have not started yet are left out; eligible bindings are included and flagged. `undecided` (default `keep`) says what closing does with items nobody decided: `revoke` makes silence mean removal. When the deployment sends email, each reviewer receives one `certification-review` message with their own item count. The result carries the campaign, its `progress`, and `items`, the number of bindings it covers. ```ts const campaign = await iam.api.certifications.create(credential, { tenantId, name: 'Q4 admin review', roleIds: [adminRole.id, billingAdminRole.id], reviewerMode: 'manager', reviewerIds: [securityLead.id], // items without an active manager go here dueAt: Date.parse('2026-12-15T17:00:00Z'), autoClose: true, undecided: 'revoke', }); ``` ```ts title="Signature" iam.api.certifications.create( credential: CredentialInput, input: { tenantId: string; name: string; roleIds?: string[]; subjectType?: 'identity' | 'group'; reviewerIds?: string[]; reviewerMode?: 'named' | 'manager'; dueAt?: number; autoClose?: boolean; undecided?: CertificationDecision; }, ): Promise<{ progress: CertificationProgress; items: number; name: string; status: 'open' | 'closed'; createdBy: string; createdAt: number; dueAt?: number; roleIds?: string[]; subjectType?: 'identity' | 'group'; reviewerIds: string[]; reviewerMode?: 'named' | 'manager'; autoClose?: boolean; undecided: CertificationDecision; closedAt?: number; closedBy?: string; id: string; tenantId: string; uniqueKey?: string; }> ``` ## decide [#decide] Records keep or revoke decisions on up to 200 items of an open campaign. **HTTP:** `POST /api/iam/certifications/decide` (requires a credential) · **Browser client:** `client.certifications.decide()` * **Permission:** `iam:certifications:review` on the campaign; when the campaign names reviewers, you must be one of them, except for items assigned to you as a manager. * **Audited as:** `iam:certifications:review`. * **Errors:** `SELF_REVIEW` for an item that certifies your own access; `ACCESS_DENIED` when you are not a reviewer of the campaign or the item is assigned to someone else's manager and you lack `iam:certifications:manage`; `CONFLICT` when the campaign is closed; `INVALID_INPUT` for an empty batch, more than 200 entries, or a decision other than `keep` or `revoke`; `NOT_FOUND` for an item that is not in this campaign. The batch is atomic: one refused entry rejects them all. Deciding an item again replaces the earlier decision and its note. A `note` holds at most 500 characters. ```ts await iam.api.certifications.decide(reviewerCredential, { tenantId, campaignId, decisions: [ { itemId: 'item_1', decision: 'keep' }, { itemId: 'item_2', decision: 'revoke', note: 'Moved to finance in July' }, ], }); ``` ```ts title="Signature" iam.api.certifications.decide( credential: CredentialInput, input: { tenantId: string; campaignId: string; decisions: CertificationDecisionInput[]; }, ): Promise<{ recorded: number }> ``` ## delete [#delete] Deletes a closed campaign and all its items. **HTTP:** `POST /api/iam/certifications/delete` (requires a credential) · **Browser client:** `client.certifications.delete()` * **Permission:** `iam:certifications:manage` on the campaign. * **Audited as:** `iam:certifications:manage`. * **Errors:** `CONFLICT` when the campaign is still open (close it first); `NOT_FOUND` when it is not in this tenant. Closed campaigns are evidence; delete one only when your retention period for review records has passed. ```ts title="Signature" iam.api.certifications.delete( credential: CredentialInput, input: { tenantId: string; campaignId: string }, ): Promise<{ deleted: boolean }> ``` ## get [#get] Returns one campaign with its items and progress. **HTTP:** `POST /api/iam/certifications/get` (requires a credential) · **Browser client:** `client.certifications.get()` * **Permission:** `iam:certifications:read` on the campaign. * **Audited as:** `iam:certifications:read`. * **Errors:** `NOT_FOUND` when the campaign is not in this tenant. Items are sorted by role and subject, each with its decision, reviewer, and, once closed, its outcome. `progress` counts `total`, `decided`, `keep`, and `revoke`. `mine: true` leaves out the items that certify your own access (directly or through a group), which you could not decide anyway. ```ts title="Signature" iam.api.certifications.get( credential: CredentialInput, input: { tenantId: string; campaignId: string; mine?: boolean }, ): Promise<{ progress: CertificationProgress; items: CertificationItem[]; name: string; status: 'open' | 'closed'; createdBy: string; createdAt: number; dueAt?: number; roleIds?: string[]; subjectType?: 'identity' | 'group'; reviewerIds: string[]; reviewerMode?: 'named' | 'manager'; autoClose?: boolean; undecided: CertificationDecision; closedAt?: number; closedBy?: string; id: string; tenantId: string; uniqueKey?: string; }> ``` ## list [#list] Lists the tenant's campaigns, newest first, each with its progress. **HTTP:** `POST /api/iam/certifications/list` (requires a credential) · **Browser client:** `client.certifications.list()` * **Permission:** `iam:certifications:read` on `iam/certifications/*`. * **Audited as:** `iam:certifications:read`. Pass `status: 'open'` or `'closed'` to filter. ```ts title="Signature" iam.api.certifications.list( credential: CredentialInput, input: { tenantId: string; status?: 'open' | 'closed' }, ): Promise<{ progress: CertificationProgress; name: string; status: 'open' | 'closed'; createdBy: string; createdAt: number; dueAt?: number; roleIds?: string[]; subjectType?: 'identity' | 'group'; reviewerIds: string[]; reviewerMode?: 'named' | 'manager'; autoClose?: boolean; undecided: CertificationDecision; closedAt?: number; closedBy?: string; id: string; tenantId: string; uniqueKey?: string; }[]> ``` ## listMine [#listmine] Lists the open campaigns that have items assigned to you as a manager, with only those items. **HTTP:** `POST /api/iam/certifications/listMine` (requires a credential) · **Browser client:** `client.certifications.listMine()` * **Permission:** None beyond an ordinary session of the tenant (not an assumed role or another tenant's session). * **Audited as:** Not audited; it only reads. * **Errors:** `ACCESS_DENIED` from a role session or a session of another tenant. This is the data a manager's review screen needs. It returns only manager-mode assignments; named reviewers use `get` with `mine: true` instead. ```ts title="Signature" iam.api.certifications.listMine( credential: CredentialInput, input: { tenantId: string }, ): Promise<{ id: string; name: string; dueAt?: number; reviewerMode: 'named' | 'manager'; items: CertificationItem[]; progress: CertificationProgress; }[]> ``` ## remind [#remind] Emails every reviewer who still has undecided items a reminder with their pending count. **HTTP:** `POST /api/iam/certifications/remind` (requires a credential) · **Browser client:** `client.certifications.remind()` * **Permission:** `iam:certifications:manage` on the campaign. * **Audited as:** `iam:certifications:manage`, plus `certification:remind` with the counts. * **Errors:** `DELIVERY_REQUIRED` when the deployment has no email delivery callback; `CONFLICT` when the campaign is closed; `NOT_FOUND` when it is not in this tenant. Managers are reminded of their assigned items and named reviewers of the undecided items that fall back to them. Only active reviewers with an email address are counted. When the campaign names no reviewers, nobody is reminded of unassigned items. Returns `reminded` (people emailed) and `pending` (undecided items). Send one a few days before `dueAt`. ```ts title="Signature" iam.api.certifications.remind( credential: CredentialInput, input: { tenantId: string; campaignId: string }, ): Promise<{ reminded: number; pending: number }> ``` ## review [#review] Records a manager's keep or revoke decisions on up to 200 items assigned to them, without a certification permission. **HTTP:** `POST /api/iam/certifications/review` (requires a credential) · **Browser client:** `client.certifications.review()` * **Permission:** None beyond an ordinary, non-impersonated session of the tenant; every item must be assigned to you. * **Audited as:** `certification:review`, with the number of keep and revoke decisions. * **Errors:** `ACCESS_DENIED` for an item that is not assigned to you, or from a role session or another tenant's session; `IMPERSONATION_RESTRICTED` from an impersonation session; `SELF_REVIEW` for your own access; `CONFLICT` when the campaign is closed; `INVALID_INPUT` for an empty batch, more than 200 entries, or an invalid decision. This lets line managers take part in reviews without holding an administrator role: being assigned the item is the authorization. Like `decide`, the batch is atomic and a later decision replaces an earlier one. ```ts await iam.api.certifications.review(managerCredential, { tenantId, campaignId, decisions: [{ itemId, decision: 'revoke', note: 'No longer on the payments team' }], }); ``` ```ts title="Signature" iam.api.certifications.review( credential: CredentialInput, input: { tenantId: string; campaignId: string; decisions: CertificationDecisionInput[]; }, ): Promise<{ recorded: number }> ``` # config (/docs/reference/api/config) > Configuration as code: export a tenant's access model as one JSON document, review changes to it, and apply it the same way to staging and production. Configuration as code: export a tenant's access model as one JSON document, review changes to it, and apply it the same way to staging and production. Hand-edited roles drift between environments and nobody can say who changed what; a document in version control gives you pull-request review, a dry run before every change, and a pipeline check that fails when someone edits production by hand. See [configuration as code](/docs/guides/privileged-access/config-as-code). ## The configuration document [#the-configuration-document] A document has `version: 1` and any of these lists, each keyed by name (never by ID), so the same file works in every environment: | Key | What it holds | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `policies` | Policies with their documents. | | `roles` | Roles with attached policy names, an inline `permissions` list or `document`, and inherited role names. | | `groups` | Groups, optionally with `members` as email addresses (membership is then made to match exactly). | | `bindings` | Group role bindings, with eligibility, activation rules, approver group, and access window. | | `resourceTypes` | Tenant-defined resource types (only with `permissions.mode: 'tenant-defined'`). | | `packages` | Access packages with their roles, groups, request settings, and optional automatic-assignment rule. | | `accessPolicy` | The tenant's activation floors for eligible bindings (`{}` clears them). | | `invariants` | Access invariants, naming groups by name and people by email. | | `agreements` | Terms-of-use agreements; a content change publishes a new version everyone accepts again. | | `departments` | [Departments](/docs/reference/api/departments) by name, with `code`, `parent` (a name), `head` and `members` (emails), and `costCenter`. | | `teams` | [Teams](/docs/reference/api/teams) by `slug`, with `parent` (a slug), `department` (a name), join settings, `maintainers` and `members` (emails), and the `roles` they hold as standing bindings. | Runtime state stays out of the document: identities, their direct role bindings, credentials, webhooks, who holds which package, temporary team memberships, and join requests. Team backing groups (`team:{slug}`) never appear under `groups` or `bindings`: a team's access is its `roles` list. The protected Owner role and policy are never exported or changed. ## How planning and pruning work [#how-planning-and-pruning-work] Only the kinds a document lists are compared. A kind the document leaves out is left alone, so a file with just `roles` never touches groups. Within a listed kind, items are matched by name and reported as `create`, `update` (with the changed `fields`, `before`, and `after`), or `unchanged`. Items of a listed kind that the document omits are deleted only when you pass `prune: true`. References between items (a role's policies, a binding's group and role, a package's roles) must exist in the document or in the tenant after the apply, or the plan fails with `INVALID_INPUT` naming the problem. | Method | What it does | Access | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`apply`](#apply) | Applies a configuration document to the tenant in one transaction, and returns the changes it made. | Credential | | [`export`](#export) | Returns the tenant's roles, policies, groups, bindings, and the other configuration kinds as a document that `plan` and `apply` accept. | Credential | | [`plan`](#plan) | Shows, without writing anything, every change `apply` would make for a configuration document. | Credential | ## apply [#apply] Applies a configuration document to the tenant in one transaction, and returns the changes it made. **HTTP:** `POST /api/iam/config/apply` (requires a credential) · **Browser client:** `client.config.apply()` * **Permission:** `iam:config:apply` on the tenant, plus the permission of the equivalent direct call for every change (for example `iam:roles:create` on the tenant, `iam:policies:update` on the policy, `iam:bindings:create` on the role, `iam:groups:update` on the group, `iam:tenants:update` for the access policy). New roles, policies, and bindings are created under your grant authority. * **Audited as:** `iam:config:apply`, and `config:apply` with metadata `prune`, the change counts, and `changed` (one line per change). * **Errors:** `INVALID_INPUT` for a malformed document or an unknown reference; `INVALID_POLICY` or `INVALID_ACTION` for a policy document storage would reject; `ACCESS_DENIED` naming the first change you are not allowed to make; `GRANT_AUTHORITY_REQUIRED` when something must be created and you hold no grant authority; `LIMIT_EXCEEDED` when a create exceeds the tenant's plan limits; `SOD_CONFLICT` when the result gives someone roles a [separation-of-duties rule](/docs/guides/authorization/separation-of-duties) forbids together; `INVARIANT_VIOLATION` when it would newly break an enforced access invariant. Every change is authorized exactly like the direct API call, so the document can never do more than you could do by hand, and one failure rolls everything back: the tenant is never left half-applied. Changes run in dependency order: policies, roles, and groups are created before the bindings and packages that name them, and are deleted only after those are gone. Changing a group binding's eligibility or activation rules replaces the binding, which ends its current activations. Always [`plan`](#plan) first and review the result. The `config-apply` [CLI command](/docs/reference/cli#config-apply) runs this call from a pipeline. ```ts const document = JSON.parse(await readFile('tenant.json', 'utf8')); const plan = await iam.api.config.plan(credential, { tenantId, config: document }); if (plan.summary.delete === 0) { const result = await iam.api.config.apply(credential, { tenantId, config: document }); console.log(result.summary); // { create: 2, update: 1, delete: 0, unchanged: 14 } } ``` ```ts title="Signature" iam.api.config.apply( credential: CredentialInput, input: { tenantId: string; config: unknown; prune?: boolean }, ): Promise<{ applied: true; tenantId: string; prune: boolean; changes: ConfigChange[]; summary: Record; }> ``` ## export [#export] Returns the tenant's roles, policies, groups, bindings, and the other configuration kinds as a document that `plan` and `apply` accept. **HTTP:** `POST /api/iam/config/export` (requires a credential) · **Browser client:** `client.config.export()` * **Permission:** `iam:config:read` on the tenant. * **Audited as:** `iam:config:read`. Use it to bootstrap version control from a tenant configured by hand, or to copy one environment's model into another. Group members are exported as lowercase email addresses (members without an email are left out), a role whose inline document was written as a `permissions` list is exported as that list again, and `invariants`, `agreements`, `departments`, and `teams` appear only when the tenant has some. The `config-export` [CLI command](/docs/reference/cli#config-export) writes it to a file. ```ts title="Signature" iam.api.config.export( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## plan [#plan] Shows, without writing anything, every change `apply` would make for a configuration document. **HTTP:** `POST /api/iam/config/plan` (requires a credential) · **Browser client:** `client.config.plan()` * **Permission:** `iam:config:read` on the tenant. * **Audited as:** `iam:config:read`. * **Errors:** `INVALID_INPUT` for a malformed document (wrong `version`, duplicate names, more than 1000 items in a list) or an unknown reference. The result lists each change with its kind, name, and action, and a `summary` with counts per action. Planning does not check whether you may make each change; `apply` does. Run it in CI on every pull request, and use the `config-plan` [CLI command](/docs/reference/cli#config-plan) with `--fail-on-drift` in a nightly job to catch changes someone made by hand. ```ts title="Signature" iam.api.config.plan( credential: CredentialInput, input: { tenantId: string; config: unknown; prune?: boolean }, ): Promise ``` # credentials (/docs/reference/api/credentials) > Credentials manages API keys: opaque bearer tokens that let a service account call Better IAM and your product without a person signing in. Credentials manages API keys: opaque bearer tokens that let a [service account](/docs/reference/api/service-accounts) call Better IAM and your product without a person signing in. Keys carry a label, record when they were last used, can be limited to a list of actions, and always expire, so you can find and remove the ones nobody needs. People never get API keys; they use sessions. The [API key hygiene guide](/docs/guides/privileged-access/lifecycle#api-key-hygiene) covers the routine. ## What a key can do [#what-a-key-can-do] A key acts as its service account: it carries the account's roles, group memberships, and relations, and policies see `principal.kind` as `service` and `principal.sessionKind` as `api-key`. Present it as `Authorization: Bearer ` over HTTP, or as `{ token }` in server calls. Two limits apply on top of the account's grants, and both only ever narrow access: * **Scopes or a session policy.** `scopes: ['documents:read']` compiles into a policy that allows exactly those actions on every resource; `policy` accepts a full [policy document](/docs/guides/authorization/policies) instead. Either one acts as a boundary: the key can do only what both the account's roles and the policy allow. * **The issuer's grant authority.** A key is tied to the [grant authority](/docs/guides/authorization/roles) of the administrator who issued it and stays within that authority's ceiling. If the authority is revoked (for example when that administrator is offboarded), the key stops working. A session token the key mints with [`sts.getSessionToken`](/docs/reference/api/sts#getsessiontoken) keeps both limits. A role session it assumes with [`roles.assume`](/docs/reference/api/roles#assume) does not: the key's scopes only decide whether it may assume the role (`iam:roles:assume`), and the role session then acts with the role's permissions, bounded by the trust's ceiling and its own session `policy`. Scope the role or the trust, not only the key. A key is refused as soon as its account is disabled, expires, or is deleted. Operations that require recent authentication, such as creating webhooks or keys, accept a key only during the first minutes after it was issued or rotated (five minutes by default), because a key's authentication time is its creation time. ## Keeping keys clean [#keeping-keys-clean] Every key has a `name` (at most 128 characters) and `description` for reviews, an `expiresAt` (90 days by default, at most a year), and a `lastUsedAt` that is recorded at most once a minute and absent until first use. `list` with `unusedForMs` finds keys nobody uses, [`analysis.findings`](/docs/reference/api/analysis#findings) reports them as `stale-api-key`, and [`reports.access`](/docs/reference/api/reports#access) lists keys unused or ending soon. Expired keys stay listed with `expired: true` until you renew or revoke them. Token material is never returned after `create` and `rotate`; only a hash is stored. | Method | What it does | Access | | ------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------- | | [`create`](#create) | Issues an API key for an active service account and returns its token, which is shown only once. | Credential | | [`get`](#get) | Returns one API key's label, lifetime, scopes, and last use, without token material. | Credential | | [`list`](#list) | Lists the API keys of the tenant or of one service account, newest first, optionally only the unused ones. | Credential | | [`revoke`](#revoke) | Deletes an API key so it stops working immediately. | Credential | | [`rotate`](#rotate) | Replaces an API key with a new token in one transaction, so the old token stops working the moment the new one exists. | Credential | | [`update`](#update) | Relabels an API key or moves its expiry; the token itself does not change. | Credential | ## create [#create] Issues an API key for an active service account and returns its token, which is shown only once. **HTTP:** `POST /api/iam/credentials/create` (requires a credential) · **Browser client:** `client.credentials.create()` * **Permission:** `iam:credentials:create` on the service account (`iam/{identityId}`), with recent authentication and an active grant authority. * **Audited as:** `iam:credentials:create`, on the service account. * **Errors:** `INVALID_IDENTITY` when the identity is not an active, unexpired service account; `NOT_FOUND` when it is not in this tenant; `INVALID_INPUT` for both `scopes` and `policy`, an empty `scopes` list, or `expiresInSeconds` outside 60 seconds to 365 days; `INVALID_ACTION` or `INVALID_POLICY` when the scopes or policy do not validate against the catalog; `GRANT_AUTHORITY_REQUIRED` when the caller holds no grant authority; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`. Because the permission is checked on the service account, you can let a team issue keys for its own integration accounts only. Give every key the narrowest scopes that work and a name that says where it is deployed. New keys are 58 characters that start with `biam_key_` and end in a checksum, so secret scanners (and the `credentialTokenScanPattern` export of `@better-iam/auth`) can recognize a leaked one; keys issued before the format existed keep working until they expire. For short-lived, narrower credentials derived from a key, such as one per CI job, use [`sts.getSessionToken`](/docs/reference/api/sts#getsessiontoken) with the key as the caller. ```ts const { token, credentialId, expiresAt } = await iam.api.credentials.create(credential, { tenantId, identityId: deployBotId, name: 'github-actions', description: 'Release workflow in acme/api', scopes: ['deployments:create', 'deployments:read'], expiresInSeconds: 30 * 86400, }); // Store `token` in the CI secret store now; it cannot be read again. ``` ```ts title="Signature" iam.api.credentials.create( credential: CredentialInput, input: { tenantId: string; identityId: string; expiresInSeconds?: number; policy?: PolicyDocument; scopes?: string[]; name?: string; description?: string; }, ): Promise<{ token: string; credentialId: string; expiresAt: number; name: string | undefined; }> ``` ## get [#get] Returns one API key's label, lifetime, scopes, and last use, without token material. **HTTP:** `POST /api/iam/credentials/get` (requires a credential) · **Browser client:** `client.credentials.get()` * **Permission:** `iam:credentials:read` on the key. * **Audited as:** `iam:credentials:read`. * **Errors:** `NOT_FOUND` when the key is not in this tenant; `INVALID_CREDENTIAL` when the id belongs to a session that is not an API key. `scopes` is present when the key was issued with `scopes` (its policy is exactly that list); otherwise `policy` shows the session policy, if any. `credentialAuthorityId` names the grant authority the key was issued under. ```ts title="Signature" iam.api.credentials.get( credential: CredentialInput, input: { tenantId: string; credentialId: string }, ): Promise ``` ## list [#list] Lists the API keys of the tenant or of one service account, newest first, optionally only the unused ones. **HTTP:** `POST /api/iam/credentials/list` (requires a credential) · **Browser client:** `client.credentials.list()` * **Permission:** `iam:credentials:read` on the service account when `identityId` is given, otherwise on the tenant. * **Audited as:** `iam:credentials:read`. * **Errors:** `INVALID_INPUT` when `unusedForMs` is negative or more than ten years. `unusedForMs` keeps only keys that have not authenticated a request in that long, counting keys never used since they were issued. Expired keys are included, marked `expired: true`. ```ts const stale = await iam.api.credentials.list(credential, { tenantId, unusedForMs: 60 * 86_400_000 }); for (const key of stale) await iam.api.credentials.revoke(credential, { tenantId, credentialId: key.id }); ``` ```ts title="Signature" iam.api.credentials.list( credential: CredentialInput, input: { tenantId: string; identityId?: string; unusedForMs?: number }, ): Promise ``` ## revoke [#revoke] Deletes an API key so it stops working immediately. **HTTP:** `POST /api/iam/credentials/revoke` (requires a credential) · **Browser client:** `client.credentials.revoke()` * **Permission:** `iam:credentials:revoke` on the key, with recent authentication. * **Audited as:** `iam:credentials:revoke`. * **Errors:** `NOT_FOUND` when the key is not in this tenant; `INVALID_CREDENTIAL` for any other kind of session (user sessions and role sessions end through their own calls); `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`. Revocation cannot be undone; issue a new key if the integration still needs access. To stop every key of an account at once, disable the account with [`serviceAccounts.setStatus`](/docs/reference/api/service-accounts#setstatus). ```ts title="Signature" iam.api.credentials.revoke( credential: CredentialInput, input: { tenantId: string; credentialId: string }, ): Promise<{ revoked: boolean }> ``` ## rotate [#rotate] Replaces an API key with a new token in one transaction, so the old token stops working the moment the new one exists. **HTTP:** `POST /api/iam/credentials/rotate` (requires a credential) · **Browser client:** `client.credentials.rotate()` * **Permission:** `iam:credentials:create` on the key, with recent authentication. * **Audited as:** `iam:credentials:create`. * **Errors:** `NOT_FOUND` when the key is not in this tenant; `INVALID_CREDENTIAL` when the id is not an API key; `ACCESS_DENIED` unless the caller issued the key (holds its grant authority) or is the platform root, or when that authority has been revoked; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`. The replacement gets a new `credentialId` and keeps the name, description, scopes or policy, and expiry of the old key. Its usage history starts over, so it shows as unused until the integration uses it. Rotate on a schedule, or at once when a token may have leaked; deploy the returned token before anything else, because the old one is already dead. ```ts title="Signature" iam.api.credentials.rotate( credential: CredentialInput, input: { tenantId: string; credentialId: string }, ): Promise<{ token: string; credentialId: string; expiresAt: number; name: string | undefined; }> ``` ## update [#update] Relabels an API key or moves its expiry; the token itself does not change. **HTTP:** `POST /api/iam/credentials/update` (requires a credential) · **Browser client:** `client.credentials.update()` * **Permission:** `iam:credentials:create` on the key; changing `expiresAt` also requires recent authentication and the key's grant authority (the issuer, or the platform root). * **Audited as:** `iam:credentials:create`. * **Errors:** `INVALID_INPUT` when nothing is given to change, or `expiresAt` is not in the future or is more than a year away; `NOT_FOUND`; `INVALID_CREDENTIAL`; `ACCESS_DENIED` when changing the expiry of a key issued under another administrator's authority; `RECENT_AUTH_REQUIRED` when changing the expiry without recent authentication. Pass `null` for `name` or `description` to clear it. `expiresAt` can shorten a key's life or extend it, including renewing a key that has already expired, which then works again without a new token. ```ts title="Signature" iam.api.credentials.update( credential: CredentialInput, input: { tenantId: string; credentialId: string; name?: string | null; description?: string | null; expiresAt?: number; }, ): Promise ``` # delegations (/docs/reference/api/delegations) > A delegation lets one AI agent act for one person, within a scope and for a limited time. A delegation lets one AI agent act for one person, within a scope and for a limited time. The person grants it directly (`grant`) or approves the agent's request (`request`, then `approve` or `deny`). The agent then exchanges its own API key for short delegated sessions (`assume`) whose identity is the person, so every decision uses the person's own grants and never more. Agents themselves are managed with [agents](/docs/reference/api/agents). The repository guide is `docs/agents.md`. ## What a delegated session may do [#what-a-delegated-session-may-do] A delegated session (a `biam_dlg_…` bearer token of session kind `delegated`) acts as the person, and three ceilings narrow what the person could do: 1. the delegation's scope: `scopes`, a list of actions (wildcards allowed) allowed on every resource, or a `policy` document; 2. the agent's `boundary`; 3. the optional scope-down `policy` the agent passed to `assume`. The limits of the agent key that opened the session also apply: its `scopes` (or session policy) and its issuer's authority. A delegation may also hold some actions back for the person to confirm one call at a time: `confirm`, a list of action patterns accepted by `grant`, `request`, and `approve` (an empty list on `approve` drops them). Those actions are refused until the person approves that action on that resource ([`requestConfirmation`](#requestconfirmation), [`decideConfirmation`](#decideconfirmation)). When the person allows it (`handoff` on `grant`, `request`, or `approve`), the agent may hand part of the delegation on to another agent with [`handoff`](#handoff); see [Hand-offs](#hand-offs). Delegated sessions never count as a recent sign-in, cannot manage delegations (apart from `handoff`) or open further delegated sessions, cannot mint session tokens (`CREDENTIAL_CHAINING_DISABLED`), and cannot obtain assertions. Every use re-validates the chain: the delegation (active, unexpired, not revoked since the session began), the person, the agent and its sponsor, the agent's `delegable` setting, and the agent key the session came from. When any link breaks, the session is refused with `UNAUTHENTICATED`. Policies see `principal.delegated`, `principal.delegationId`, and the agent's keys ([principal keys](/docs/guides/authorization/conditions#principal-keys)), and audit events recorded in the session name the person as actor with `agentId` and `delegationId` in their session context. ## Lifecycle and who may act [#lifecycle-and-who-may-act] | Status | Meaning | | --------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `pending` | The agent asked with `request` and the person has not decided. `expiresAt` is when the request lapses, seven days after it was made. | | `active` | The agent may open delegated sessions until `expiresAt`. | | `denied` | The person turned the request down. | | `revoked` | Ended early by the person, the agent, its sponsor, or an administrator, or because the person was offboarded or deleted or the agent deleted. | A lapsed request or an ended delegation keeps its status and reads `expired: true`. One pending or active delegation may link an agent and a person at a time. The person decides in their own signed-in session (`grant`, `approve`, `deny`); the agent acts with its own API key (`request`, `assume`). The people involved (the person, the agent with its own key, and the agent's sponsor) read, follow (`activity`), and revoke a delegation without any permission (for a hand-off, so does the agent that handed it on, in its delegated session); anyone else needs `iam:delegations:read` or `iam:delegations:revoke` on `iam/{delegationId}`, and `list` needs `iam:delegations:read` on the tenant. ## Spending caps [#spending-caps] `spend: { period, maxCostUsd?, maxTokens?, maxRequests? }` on `grant`, `request`, `approve` (where `null` removes it), or `handoff` caps the AI model calls made under the delegation per `minute`, `hour`, `day`, or `month`, with at least one limit. Every [inference](/docs/reference/api/inference) call in a delegated session of the delegation, or of a hand-off below it, counts like a budget: past the cap, calls are refused with `BUDGET_EXCEEDED` until the window resets, and the first refusal is audited as `inference:budget-exceeded` on `delegation:{delegationId}`. Summaries report the cap and what the current window used as `spend` (`maxCostUsd`, `maxTokens`, `maxRequests`, `usedCostUsd`, `usedTokens`, `usedRequests`, `resetsAt`). An invalid setting (another period, no limit, or a limit out of range) is `INVALID_INPUT`. ## Hand-offs [#hand-offs] A hand-off is a delegation an agent creates from its own, for another agent, while acting for the person: `requestedBy` is `handoff`, `parentId` names the delegation above, and `chain` lists the agents above it (the person's own delegate first). The person allows hand-offs with `handoff: { agents?, depth? }`: `agents` limits them to named agents of the tenant (at most 20; any agent that accepts delegation when left out), and `depth` (1 to 3, 1 by default) is how many hand-offs may follow one another. Each hand-off gets the depth left, and the person's `confirm` list travels down with it. A hand-off never allows more than its own scope, every delegation above it, the ceilings of every agent in the chain, and the limits of the session that handed it on (its scope-down policy, its key's scopes, and that key's issuer). Every use checks the whole chain, so a hand-off stops working when a delegation above it ends, an agent above it is suspended, loses its sponsor, or stops accepting delegation, or the API key the handing agent acted with is revoked or expires (a hand-off never lasts past that key). Revoking a delegation revokes the hand-offs below it; when an agent revokes one from its delegated session, `revokedBy` names the agent. The handing agents' own inference budgets count a hand-off's model calls too. Hand-offs do not count toward the rule that one pending or active delegation may link an agent and a person. Policies see the chain as `principal.delegationChain`. | Method | What it does | Access | | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`activity`](#activity) | Returns what happened under a delegation, newest first: its lifecycle and everything the agent did for the person. | Credential | | [`approve`](#approve) | Approves an agent's pending request, optionally with a different scope, lifetime, or session cap. | Credential | | [`assume`](#assume) | Opens a delegated session in which the agent acts for the person, and returns its bearer token once. | Credential | | [`decideConfirmation`](#decideconfirmation) | Approves or rejects an agent's pending confirmation request. | Credential | | [`deny`](#deny) | Turns down an agent's pending request. | Credential | | [`get`](#get) | Returns one delegation to the person, the agent, or the agent's sponsor, or to an administrator. | Credential | | [`getConfirmation`](#getconfirmation) | Returns one confirmation request to the person it asks or to the agent that made it. | Credential | | [`grant`](#grant) | Lets an agent act for you, within a scope and for a limited time. | Credential | | [`handoff`](#handoff) | Hands part of the delegation an agent acts under on to another agent, for the same person. | Credential | | [`issueToken`](#issuetoken) | Gives an agent acting for a person a delegation token: a short-lived signed JWT that shows one service outside Better IAM that the agent acts for that person. | Credential | | [`list`](#list) | Lists the tenant's delegations, newest first, filtered by agent, person, or status. | Credential | | [`listConfirmations`](#listconfirmations) | Lists confirmation requests newest first: those addressed to the caller, or those the calling agent made. | Credential | | [`listMine`](#listmine) | Returns your own delegations, newest first: the agents acting or asking to act for you, or, for an agent, the people it acts for. | Credential | | [`request`](#request) | Asks a person to delegate to the calling agent, and emails them the request when the deployment sends email. | Credential | | [`requestConfirmation`](#requestconfirmation) | Asks the person an agent acts for to confirm one action the delegation holds back. | Credential | | [`revoke`](#revoke) | Ends a pending or active delegation and deletes its live delegated sessions at once. | Credential | ## activity [#activity] Returns what happened under a delegation, newest first: its lifecycle and everything the agent did for the person. **HTTP:** `POST /api/iam/delegations/activity` (requires a credential) · **Browser client:** `client.delegations.activity()` * **Permission:** None for the person, the agent with its own key, or the agent's sponsor; anyone else needs `iam:delegations:read` on the delegation. * **Audited as:** `iam:delegations:read` when read with the permission; not audited for the people involved. * **Errors:** `NOT_FOUND` when the delegation is not in this tenant (for a caller with the permission); `ACCESS_DENIED` for anyone else; `INVALID_INPUT` for a `limit` outside 1 to 500 or a malformed `offset`, `from`, or `to`. The events are those whose resource is the delegation (`delegation:grant`, `delegation:request`, `delegation:approve`, `delegation:deny`, `delegation:assume`, `delegation:revoke`) and those recorded for the agent's delegated sessions under it (`sessionContext.delegationId`), allowed and denied, together with the same for every [hand-off](#hand-offs) below it. It answers the person's question "what did the agent do on my behalf?". Page with `limit` (100 by default) and `offset`, and bound the time with `from` and `to` (epoch milliseconds). ```ts const trail = await iam.api.delegations.activity(aliceSession, { tenantId, delegationId }); ``` ```ts title="Signature" iam.api.delegations.activity( credential: CredentialInput, input: ActivityQuery & { tenantId: string; delegationId: string }, ): Promise ``` ## approve [#approve] Approves an agent's pending request, optionally with a different scope, lifetime, or session cap. **HTTP:** `POST /api/iam/delegations/approve` (requires a credential) · **Browser client:** `client.delegations.approve()` * **Permission:** The person the request names, in their own signed-in session with a recent sign-in. * **Audited as:** `delegation:approve`, with the agent and the new `expiresAt`. * **Errors:** `NOT_FOUND` when the request is not addressed to you (administrators included); `INVALID_TRANSITION` (409) when it is no longer pending or has lapsed; `RECENT_AUTH_REQUIRED` without a recent sign-in; `IMPERSONATION_RESTRICTED` from an impersonation session; `DELEGATION_NOT_ALLOWED` or `INVALID_IDENTITY` (409) when the agent stopped accepting delegation or is no longer in good standing; `INVALID_INPUT`, `INVALID_ACTION`, or `INVALID_POLICY` for a new scope or an out-of-range lifetime. Pass `scopes` or `policy` (not both) to replace the scope the agent asked for, usually to narrow it; without either, the requested scope stands. The delegation lasts `expiresInSeconds` from now (300 seconds to one year), by default as long as the agent asked for (`requestedSeconds`). `maxSessionSeconds` (60 to 43200) caps each delegated session. `handoff` lets the agent hand work on ([Hand-offs](#hand-offs)). An agent's requested `handoff` counts only when you state `handoff` here yourself; a plain approval (or `null`) leaves hand-offs out. ```ts await iam.api.delegations.approve(aliceSession, { tenantId, delegationId: request.id, scopes: ['calendar:read'], }); ``` ```ts title="Signature" iam.api.delegations.approve( credential: CredentialInput, input: DelegationScopeInput & { tenantId: string; delegationId: string; expiresInSeconds?: number; maxSessionSeconds?: number; }, ): Promise ``` ## assume [#assume] Opens a delegated session in which the agent acts for the person, and returns its bearer token once. **HTTP:** `POST /api/iam/delegations/assume` (requires a credential) · **Browser client:** `client.delegations.assume()` * **Permission:** The delegation's agent, with its own API key. * **Audited as:** `delegation:assume`, with the person, the new session's id, and `durationSeconds`. * **Errors:** `ACCESS_DENIED` for any other credential (a delegated token cannot open another session); `NOT_FOUND` when the delegation is not this agent's; `DELEGATION_PENDING` (409) while the person has not decided; `DELEGATION_INACTIVE` (403) when the delegation was denied, revoked, lapsed, or ended, or the person is no longer active; `DELEGATION_NOT_ALLOWED` (403) when the agent's `delegable` is off; `LIMIT_EXCEEDED` (409) when the delegation already holds 20 live sessions; `INVALID_INPUT` for a `durationSeconds` out of range or a malformed `sessionName`; `INVALID_POLICY` or `INVALID_ACTION` for the scope-down `policy`. The token's identity is the person (`session.identityId`); `session.agentId` and `session.delegationId` name the agent and the delegation. It lasts `durationSeconds`, from 60 up to the smaller of the agent's `maxDelegatedSessionSeconds` (3600 when unset) and the delegation's `maxSessionSeconds`, and by default 900 seconds or that limit if it is lower. It never outlives the delegation or the agent key that opened it. `sessionName` (2 to 64 letters, digits, or `+=,.@_-`) appears in the session context of audit events. Each call also records `lastUsedAt` on the delegation. ```ts const { token, expiresAt, session } = await iam.api.delegations.assume(agentKey, { tenantId, delegationId, durationSeconds: 600, sessionName: 'triage-run-42', }); await iam.require({ token, tenantId, action: 'tickets:update', resource: { type: 'ticket', id: 'T-1' } }); ``` ```ts title="Signature" iam.api.delegations.assume( credential: CredentialInput, input: { tenantId: string; delegationId: string; durationSeconds?: number; sessionName?: string; policy?: PolicyDocument; }, ): Promise ``` ## decideConfirmation [#decideconfirmation] Approves or rejects an agent's pending confirmation request. **HTTP:** `POST /api/iam/delegations/decideConfirmation` (requires a credential) · **Browser client:** `client.delegations.decideConfirmation()` * **Permission:** None beyond the person's own session (not impersonated) of the tenant; only the person the request asks may decide it. * **Audited as:** `delegation:confirm` or `delegation:reject`, on the delegation, with the confirmation id, the action, and the resource. * **Errors:** `NOT_FOUND` when the request does not exist or asks someone else; `INVALID_TRANSITION` (409) when it was already decided or has lapsed; `DELEGATION_INACTIVE` (403) when the delegation ended meanwhile; `ACCESS_DENIED` from any other credential; `INVALID_INPUT` when `approve` is not a boolean. `approve: true` opens the requested action on the requested resource, for the agent acting for the person under this delegation, until `validSeconds` after the decision. `approve: false` rejects it; nothing opens. No recent sign-in is needed, so the person can answer from a notification right away. ```ts title="Signature" iam.api.delegations.decideConfirmation( credential: CredentialInput, input: { tenantId: string; confirmationId: string; approve: boolean }, ): Promise ``` ## deny [#deny] Turns down an agent's pending request. **HTTP:** `POST /api/iam/delegations/deny` (requires a credential) · **Browser client:** `client.delegations.deny()` * **Permission:** The person the request names, in their own signed-in session. * **Audited as:** `delegation:deny`, with the agent. * **Errors:** `NOT_FOUND` when the request is not addressed to you; `INVALID_TRANSITION` (409) when it is no longer pending or has lapsed; `IMPERSONATION_RESTRICTED` from an impersonation session. No recent sign-in is needed. The request becomes `denied`; the agent sees that when it polls `get`, and may ask again with a new `request`. ```ts title="Signature" iam.api.delegations.deny( credential: CredentialInput, input: { tenantId: string; delegationId: string }, ): Promise ``` ## get [#get] Returns one delegation to the person, the agent, or the agent's sponsor, or to an administrator. **HTTP:** `POST /api/iam/delegations/get` (requires a credential) · **Browser client:** `client.delegations.get()` * **Permission:** None for the person, the agent (with its own key), or the agent's sponsor; anyone else needs `iam:delegations:read` on the delegation. * **Audited as:** Not audited for the parties involved; `iam:delegations:read` for administrators. * **Errors:** `NOT_FOUND` when the delegation is not in this tenant. An agent that asked with `request` polls this until `status` leaves `pending`. The summary names the agent (with its model and provider) and the person, the scope as `scopes` (when given as actions) and as the compiled `policy`, who created it (`requestedBy`), the agent's `reason`, and its times; `expired` is true for a lapsed request or an active delegation past its end. It never contains session tokens. ```ts title="Signature" iam.api.delegations.get( credential: CredentialInput, input: { tenantId: string; delegationId: string }, ): Promise ``` ## getConfirmation [#getconfirmation] Returns one confirmation request to the person it asks or to the agent that made it. **HTTP:** `POST /api/iam/delegations/getConfirmation` (requires a credential) · **Browser client:** `client.delegations.getConfirmation()` * **Permission:** None: the person's own session, the agent's own key, or a delegated session of the same delegation. * **Audited as:** Not audited; it only reads. * **Errors:** `NOT_FOUND` when the request does not exist or is not visible to the caller; `ACCESS_DENIED` for any other credential. Agents poll it while `status` is `pending`. `expired` is true for a pending request past its decision window and for an approval past its validity. ```ts title="Signature" iam.api.delegations.getConfirmation( credential: CredentialInput, input: { tenantId: string; confirmationId: string }, ): Promise ``` ## grant [#grant] Lets an agent act for you, within a scope and for a limited time. **HTTP:** `POST /api/iam/delegations/grant` (requires a credential) · **Browser client:** `client.delegations.grant()` * **Permission:** Your own signed-in session of the tenant with a recent sign-in; no `iam:*` permission. * **Audited as:** `delegation:grant`, with the agent, `expiresAt`, and the scopes. * **Errors:** `DELEGATION_EXISTS` (409) when a pending request or an active delegation already links you and the agent; `DELEGATION_NOT_ALLOWED` (403) when the agent does not accept delegation; `INVALID_IDENTITY` (409) when the agent is not in good standing; `NOT_FOUND` when the id is not an agent of this tenant; `RECENT_AUTH_REQUIRED` without a recent sign-in; `IMPERSONATION_RESTRICTED` from an impersonation session; `ACCESS_DENIED` from an API key, role session, session token, or delegated session; `INVALID_INPUT` for a missing or doubled scope or an out-of-range lifetime; `INVALID_ACTION` or `INVALID_POLICY` for a scope the catalog does not accept. Give exactly one of `scopes` (actions, wildcards allowed) or `policy` (a policy document). Either way the scope only narrows: the agent acts with your grants, so it can never do more than you can. `expiresInSeconds` runs from 300 seconds to one year (30 days by default), and `maxSessionSeconds` (60 to 43200) caps each delegated session below the agent's own limit. When the agent has already asked, approve its request instead. [`agents.catalog`](/docs/reference/api/agents#catalog) lists the agents you can delegate to. ```ts const delegation = await iam.api.delegations.grant(aliceSession, { tenantId, agentId, scopes: ['tickets:read', 'tickets:update'], expiresInSeconds: 30 * 86_400, maxSessionSeconds: 900, }); ``` `handoff` lets the agent hand parts of the delegation on to other agents ([Hand-offs](#hand-offs)); an invalid setting (an unknown agent, more than 20 agents, or a depth outside 1 to 3) is `INVALID_INPUT`. ```ts title="Signature" iam.api.delegations.grant( credential: CredentialInput, input: DelegationScopeInput & { tenantId: string; agentId: string; expiresInSeconds?: number; maxSessionSeconds?: number; }, ): Promise ``` ## handoff [#handoff] Hands part of the delegation an agent acts under on to another agent, for the same person. **HTTP:** `POST /api/iam/delegations/handoff` (requires a credential) · **Browser client:** `client.delegations.handoff()` * **Permission:** A delegated session (from `assume`) whose delegation allows hand-offs to that agent; no `iam:*` permission. * **Audited as:** `delegation:handoff` on the new delegation, with `parentId`, `fromAgentId`, `toAgentId`, `depth`, and the scopes; the actor is the person, with the handing agent in the session context. * **Errors:** `ACCESS_DENIED` for any credential but a delegated session of the tenant; `DELEGATION_INACTIVE` (403) when the delegation (or one above it) has ended; `DELEGATION_NOT_ALLOWED` (403) when the person did not allow hand-offs, not to that agent, or no more of them down the line, when the agent is already in the chain, or when it does not accept delegation; `INVALID_IDENTITY` (409) when it is not in good standing; `NOT_FOUND` when the id is not an agent of this tenant; `LIMIT_EXCEEDED` (409) when the delegation already holds 20 live hand-offs; `INVALID_INPUT`, `INVALID_ACTION`, or `INVALID_POLICY` for the scope, `confirm`, or an `expiresInSeconds` outside 60 seconds to one year. Give `agentId` and exactly one of `scopes` or `policy`. The result is a new active delegation from the same person to that agent (see [Hand-offs](#hand-offs) for what bounds it). It lasts `expiresInSeconds` (one hour by default), never past the delegation above. It keeps the person's `confirm` list plus any `confirm` given here, and `maxSessionSeconds` never above the delegation above. `reason` (up to 1024 characters) is shown to the person. Pass the new delegation's `id` to the other agent, for example in an A2A message; it opens its own sessions for the person with `assume` and its own key. ```ts const handoff = await iam.api.delegations.handoff( { token: delegatedToken }, { tenantId, agentId: researcherId, scopes: ['documents:read'], reason: 'Find sources for the report' }, ); ``` ```ts title="Signature" iam.api.delegations.handoff( credential: CredentialInput, input: Omit & { tenantId: string; agentId: string; expiresInSeconds?: number; maxSessionSeconds?: number; reason?: string; }, ): Promise ``` ## issueToken [#issuetoken] Gives an agent acting for a person a delegation token: a short-lived signed JWT that shows one service outside Better IAM that the agent acts for that person. **HTTP:** `POST /api/iam/delegations/issueToken` (requires a credential) · **Browser client:** `client.delegations.issueToken()` * **Permission:** A delegated session (from `assume`); no `iam:*` permission. The deployment needs the `a2a` option, because its card keys sign the token. * **Audited as:** `delegation:token-issue` on the delegation, with the `audience`, `tokenId`, `expiresAt`, and the scopes; the actor is the person, with the agent in the session context. * **Errors:** `FEATURE_DISABLED` (403) without the `a2a` option; `ACCESS_DENIED` for any credential but a delegated session of the tenant; `DELEGATION_INACTIVE` (403) when the delegation or one above it has ended; `DELEGATION_NOT_ALLOWED` (403) when: * an agent in the chain does not list the audience in its `tokenAudiences`; * some limit on the session does not allow a scope outright; * a scope is one the person confirms call by call; * a deny among the person's own grants could touch a scope. `INVALID_INPUT` for an audience that is not an http(s) URL or other absolute URI, that has user info, a query or a fragment, or that contains `*`; for a malformed scope (letters, digits, `:_./-`, and `*`; at most 50); or for a `lifetimeSeconds` outside 30 to 3600. `RATE_LIMITED` (429) past the per-delegation budget. Give `audience`, the one service the token is for. It must match the [`tokenAudiences`](/docs/reference/api/agents#profile-and-ceiling) of the agent and of every agent that handed the work to it. `scopes` defaults to the delegation's own scopes (none for a delegation given as a policy). Each scope must be allowed outright, on every resource and without conditions, by every limit the session is under: the delegation and those above it, the agents' ceilings, the session's scope-down policy, its key's scopes and issuer, and the person's and tenant's boundaries. No scope may be an action the person confirms call by call (`confirm` anywhere in the chain), nor one a deny statement among the person's own grants could touch. The token lasts `lifetimeSeconds` (300 by default), never past the session or any delegation in the chain, and is recorded until it expires so a live check can re-examine it. The result is `{ token, tokenType: 'biam-delegation+jwt', tokenId, issuer, audience, scopes, chain, expiresAt, expiresIn }`. The JWT's header is `{ alg, kid, typ: 'biam-delegation+jwt', jku }`. Its claims are `iss`, `sub` (the person), `aud`, `iat`, `nbf`, `exp`, `jti`, `tenant_id`, `delegation_id`, `act`, and `scope` (space-separated, when there are scopes). `act` is the agent (`{ sub }`), with the agents that handed the work on nested inside (RFC 8693 section 4.1). Services verify it with `verifyDelegationToken` from `@better-iam/a2a` against the deployment's card keys. A service next to the deployment can use `iam.a2a.verifyDelegationToken(token, { audience, live: true })`. It re-checks the delegation chain, the person, the agents and the acting agent's key, the audience, and the scopes against the current state, so a revocation or a narrowed limit takes effect before the token expires. ```ts const { token } = await iam.api.delegations.issueToken( { token: delegatedToken }, { tenantId, audience: 'https://api.calendar.example', scopes: ['calendar:read'] }, ); ``` ```ts title="Signature" iam.api.delegations.issueToken( credential: CredentialInput, input: { tenantId: string; audience: string; scopes?: string[]; lifetimeSeconds?: number; }, ): Promise ``` ## list [#list] Lists the tenant's delegations, newest first, filtered by agent, person, or status. **HTTP:** `POST /api/iam/delegations/list` (requires a credential) · **Browser client:** `client.delegations.list()` * **Permission:** `iam:delegations:read` on the tenant. * **Audited as:** `iam:delegations:read`. `status` is `pending`, `active`, `denied`, or `revoked`; lapsed requests and ended delegations keep their status and carry `expired: true`. ```ts title="Signature" iam.api.delegations.list( credential: CredentialInput, input: { tenantId: string; agentId?: string; subjectId?: string; status?: Delegation['status']; }, ): Promise ``` ## listConfirmations [#listconfirmations] Lists confirmation requests newest first: those addressed to the caller, or those the calling agent made. **HTTP:** `POST /api/iam/delegations/listConfirmations` (requires a credential) · **Browser client:** `client.delegations.listConfirmations()` * **Permission:** None: a person's own session sees the requests that ask them, an agent's own key every request it made, and a delegated session those of its delegation. * **Audited as:** Not audited; it only reads. * **Errors:** `ACCESS_DENIED` for any other credential or tenant. Filter with `status` (`pending`, `approved`, or `rejected`). It backs an "actions waiting for your confirmation" list. ```ts title="Signature" iam.api.delegations.listConfirmations( credential: CredentialInput, input: { tenantId: string; status?: DelegationConfirmation['status'] }, ): Promise ``` ## listMine [#listmine] Returns your own delegations, newest first: the agents acting or asking to act for you, or, for an agent, the people it acts for. **HTTP:** `POST /api/iam/delegations/listMine` (requires a credential) · **Browser client:** `client.delegations.listMine()` * **Permission:** A person's own signed-in session, or an agent's own API key, in the tenant. * **Audited as:** Not audited; it only reads. * **Errors:** `ACCESS_DENIED` for other credentials (role sessions, session tokens, delegated sessions, a session of another tenant); `IMPERSONATION_RESTRICTED` from an impersonation session. Every status is included, so a settings page can show requests to decide, delegations to revoke, and the history. ```ts title="Signature" iam.api.delegations.listMine( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## request [#request] Asks a person to delegate to the calling agent, and emails them the request when the deployment sends email. **HTTP:** `POST /api/iam/delegations/request` (requires a credential) · **Browser client:** `client.delegations.request()` * **Permission:** The agent's own API key. * **Audited as:** `delegation:request`, with the person and the scopes. * **Errors:** `ACCESS_DENIED` for any credential but an agent's own key; `RATE_LIMITED` (429) when the agent asks too often; `NOT_FOUND` when no active person of the tenant has that id or email; `DELEGATION_EXISTS` (409) when a pending request or an active delegation already links the agent and the person; `DELEGATION_NOT_ALLOWED` when the agent's `delegable` is off; `INVALID_INPUT` without exactly one of `subjectId` and `subjectEmail`, without a `reason`, or for scope and lifetime values as in `grant`. Name the person by `subjectId` or `subjectEmail`; `reason` (up to 1024 characters) is shown to them. The request waits up to seven days for their decision. `expiresInSeconds` is how long the delegation will last once approved (reported as `requestedSeconds`); the person may change it and the scope when approving. When the person has an email address and the deployment configures `sendEmail`, they receive the `delegation-request` email, which links to your approval page through `links.delegation` in the email templates. Each request counts toward a per-agent rate limit, even when it is refused. ```ts const request = await iam.api.delegations.request(agentKey, { tenantId, subjectEmail: 'alice@acme.test', scopes: ['calendar:read', 'calendar:write'], reason: 'Schedule your interviews for next week', expiresInSeconds: 7 * 86_400, }); ``` ```ts title="Signature" iam.api.delegations.request( credential: CredentialInput, input: DelegationScopeInput & { tenantId: string; subjectId?: string; subjectEmail?: string; reason: string; expiresInSeconds?: number; maxSessionSeconds?: number; }, ): Promise ``` ## requestConfirmation [#requestconfirmation] Asks the person an agent acts for to confirm one action the delegation holds back. **HTTP:** `POST /api/iam/delegations/requestConfirmation` (requires a credential) · **Browser client:** `client.delegations.requestConfirmation()` * **Permission:** None beyond the agent's delegated session (from `assume`) in the delegation's tenant. * **Audited as:** `delegation:confirmation-request`, on the delegation, with the confirmation id, the action, and the resource. * **Errors:** `ACCESS_DENIED` for any credential other than a delegated session; `INVALID_INPUT` when the action does not match the delegation's `confirm` patterns, or for a malformed action, resource, reason, or `validSeconds` outside 30 to 3600; `RATE_LIMITED` (429) when the delegation asks too often. A delegation created with `confirm` (action patterns, on `grant`, `request`, or `approve`) refuses those actions to its delegated sessions until the person approves them one call at a time: the decision is refused with the internal reason `CONFIRMATION_REQUIRED` (callers see an ordinary denial). The agent names the `action` and the `resource` it wants to act on and a `reason` for the person; the request waits up to 30 minutes and is emailed to the person (template `delegation-confirmation`) when the deployment sends email. Asking again for the same action and resource while a request is pending returns that request. Once approved, exactly that action on that resource is allowed for `validSeconds` (300 by default); the agent polls `getConfirmation` and retries. ```ts const request = await iam.api.delegations.requestConfirmation(delegatedSession, { tenantId, action: 'documents:delete', resource: { type: 'document', id: 'q3-draft' }, reason: 'You asked me to clean up the drafts folder', validSeconds: 120, }); ``` ```ts title="Signature" iam.api.delegations.requestConfirmation( credential: CredentialInput, input: { tenantId: string; action: string; resource: { type: string; id: string }; reason: string; validSeconds?: number; }, ): Promise ``` ## revoke [#revoke] Ends a pending or active delegation and deletes its live delegated sessions at once. **HTTP:** `POST /api/iam/delegations/revoke` (requires a credential) · **Browser client:** `client.delegations.revoke()` * **Permission:** None for the person, the agent (with its own key), or the agent's sponsor; anyone else needs `iam:delegations:revoke` on the delegation. * **Audited as:** `delegation:revoke`, with the agent, the person, `sessionsEnded`, `handoffsRevoked` (when hand-offs below it ended too), and the `reason`; an administrator's call also as `iam:delegations:revoke`. * **Errors:** `INVALID_TRANSITION` (409) when it is already denied or revoked; `NOT_FOUND` when it is not in this tenant. `reason` (up to 512 characters) goes to the audit event. A revoked delegation cannot be used again; the agent needs a new `grant` or an approved `request`. To stop an agent for everyone at once, suspend it with [`agents.suspend`](/docs/reference/api/agents#suspend). ```ts title="Signature" iam.api.delegations.revoke( credential: CredentialInput, input: { tenantId: string; delegationId: string; reason?: string }, ): Promise ``` # departments (/docs/reference/api/departments) > Departments are the organization's reporting structure: Engineering, Finance, Sales, and their sub-departments. Departments are the organization's reporting structure: Engineering, Finance, Sales, and their sub-departments. Each person belongs to at most one department, a department can name a head, a code, and a cost center, and teams can be filed under a department. Teams, the working units, are the [`teams`](/docs/reference/api/teams) group. ## Departments in policies [#departments-in-policies] Every evaluation for a person in their own tenant can read two [condition](/docs/guides/authorization/conditions) keys, loaded only when a policy names them: * `principal.departments`: the person's department ID and the IDs of every department above it. * `principal.departmentId`: the person's own department; absent without one. ```json { "effect": "allow", "actions": ["documents:write"], "resources": ["*"], "conditions": { "StringEquals": { "principal.departmentId": "${resource.departmentId}" } } } ``` `{ "ArrayContains": { "principal.departments": [""] } }` admits everyone in Engineering and its sub-departments. Sessions of an assumed role see an empty list. ## Managers from the org chart [#managers-from-the-org-chart] `syncManagers` sets each person's manager (`managerId`) to the head of their department, and a head's to the nearest head above. Approvals routed to managers (eligible bindings and access packages with `managerApproval`, certification campaigns with `reviewerMode: 'manager'`) then follow the org chart. ## Birthright packages [#birthright-packages] [Access package rules](/docs/guides/privileged-access/automatic-assignment) may test `identity.departments`: a person's department ID and the IDs of the departments above it. `assign`, `unassign`, `importFromAttribute`, moving or deleting a department, and `syncManagers` (rules may test `identity.managerId`) re-evaluate the rules for the people they touch once they commit: joiners get their department's packages at once, and movers and leavers lose them. | Method | What it does | Access | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`assign`](#assign) | Places up to 100 people (`identityIds`, or one `identityId`) in a department, moving them out of any other, with an optional `title`. Returns how many changed. | Credential | | [`create`](#create) | Creates a department, optionally under `parentId`, with a `code`, a `headId` (an active person of the organization), a `costCenter`, and a `description`. | Credential | | [`delete`](#delete) | Deletes a department. Its people become unassigned and its teams lose the link. | Credential | | [`get`](#get) | One department with its path (the departments above it), sub-departments, head, and teams, and member counts with and without the departments below. | Credential | | [`importFromAttribute`](#importfromattribute) | Places every active person whose string identity attribute (such as `department`, filled by SCIM provisioning or an onboarding form) names a department, matched by name or code ignoring case. `createMissing` creates top-level departments for values nothing matches; `dryRun` reports without changing anything. | Credential | | [`list`](#list) | Every department with member counts (with and without sub-departments), child and team counts, in name order. | Credential | | [`listMembers`](#listmembers) | The people of a department, heads first; `includeSubdepartments` adds those of every department below, each with their department. Each entry carries the person's title, since when they are in the department, and their manager. | Credential | | [`mine`](#mine) | Your own place in the org chart and, if you head departments, the people you lead. | Credential | | [`ofIdentity`](#ofidentity) | A person's department with the path from the top, their title, since when, the department's head and cost center; null when they have none. | Credential | | [`suggestBirthright`](#suggestbirthright) | Roles and groups that most of a department's people already hold by hand, proposed as a ready-made automatic access package whose rule names the department. | Credential | | [`syncManagers`](#syncmanagers) | Makes department heads the managers of their departments' people (see above). Without `overwrite` only people without a manager change; `departmentId` limits the run to one department and those below it; `dryRun` reports only. Returns the changes with names, how many kept another manager, and how many had no head above them. Never creates a cycle. | Credential | | [`tree`](#tree) | The org chart: top-level departments with their sub-departments, heads, and member counts. | Credential | | [`unassign`](#unassign) | Takes a person out of their department. | Credential | | [`update`](#update) | Renames, re-codes, moves (`parentId`, null for top level), or changes the head, cost center, or description of a department; null (or an empty string) clears an optional field. | Credential | ## assign [#assign] Places up to 100 people (`identityIds`, or one `identityId`) in a department, moving them out of any other, with an optional `title`. Returns how many changed. **HTTP:** `POST /api/iam/departments/assign` (requires a credential) · **Browser client:** `client.departments.assign()` * **Permission:** `iam:departments:manage` on `iam/{departmentId}`. * **Audited as:** `iam:departments:manage` and `department:assign` per person (`previousDepartmentId` when moved). * **Errors:** `INVALID_INPUT` for a service account or agent, or without people; `NOT_FOUND`. ```ts title="Signature" iam.api.departments.assign( credential: CredentialInput, input: { tenantId: string; departmentId: string; identityIds?: string[]; identityId?: string; title?: string; }, ): Promise<{ assigned: number; unchanged: number }> ``` ## create [#create] Creates a department, optionally under `parentId`, with a `code`, a `headId` (an active person of the organization), a `costCenter`, and a `description`. **HTTP:** `POST /api/iam/departments/create` (requires a credential) · **Browser client:** `client.departments.create()` * **Permission:** `iam:departments:manage` on the tenant. * **Audited as:** `iam:departments:manage` and `department:create`. * **Errors:** `CONFLICT` (409) when the name or code (ignoring case) is taken; `INVALID_INPUT` for a bad code, a head who is not a person, or more than twenty levels of nesting; `LIMIT_EXCEEDED` past 2000 departments. ```ts const engineering = await iam.api.departments.create(credential, { tenantId, name: 'Engineering', code: 'ENG', headId, costCenter: 'CC-100', }); ``` ```ts title="Signature" iam.api.departments.create( credential: CredentialInput, input: DepartmentInput, ): Promise ``` ## delete [#delete] Deletes a department. Its people become unassigned and its teams lose the link. **HTTP:** `POST /api/iam/departments/delete` (requires a credential) · **Browser client:** `client.departments.delete()` * **Permission:** `iam:departments:manage` on the department. * **Audited as:** `iam:departments:manage` and `department:delete` (`unassigned`, `teams`). * **Errors:** `RESOURCE_IN_USE` (409) while departments sit below it, or while an access package rule names it (`identity.departments`). ```ts title="Signature" iam.api.departments.delete( credential: CredentialInput, input: { tenantId: string; departmentId: string }, ): Promise<{ unassigned: number; teams: number; deleted: true }> ``` ## get [#get] One department with its path (the departments above it), sub-departments, head, and teams, and member counts with and without the departments below. **HTTP:** `POST /api/iam/departments/get` (requires a credential) · **Browser client:** `client.departments.get()` * **Permission:** `iam:departments:read` on the department. ```ts title="Signature" iam.api.departments.get( credential: CredentialInput, input: { tenantId: string; departmentId: string }, ): Promise ``` ## importFromAttribute [#importfromattribute] Places every active person whose string identity attribute (such as `department`, filled by SCIM provisioning or an onboarding form) names a department, matched by name or code ignoring case. `createMissing` creates top-level departments for values nothing matches; `dryRun` reports without changing anything. **HTTP:** `POST /api/iam/departments/importFromAttribute` (requires a credential) · **Browser client:** `client.departments.importFromAttribute()` * **Permission:** `iam:departments:manage` on the tenant. * **Audited as:** `iam:departments:manage`, and `department:create` / `department:assign` for what changed. * **Errors:** `INVALID_INPUT` when the attribute is not a declared string identity attribute. ```ts const result = await iam.api.departments.importFromAttribute(credential, { tenantId, attribute: 'department', createMissing: true, dryRun: true, }); // { dryRun: true, created: ['Sales'], assigned: 12, unchanged: 30, unmatched: [], missing: 2 } ``` ```ts title="Signature" iam.api.departments.importFromAttribute( credential: CredentialInput, input: { tenantId: string; attribute: string; createMissing?: boolean; dryRun?: boolean; }, ): Promise ``` ## list [#list] Every department with member counts (with and without sub-departments), child and team counts, in name order. **HTTP:** `POST /api/iam/departments/list` (requires a credential) · **Browser client:** `client.departments.list()` * **Permission:** `iam:departments:read` on the tenant. ```ts title="Signature" iam.api.departments.list( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listMembers [#listmembers] The people of a department, heads first; `includeSubdepartments` adds those of every department below, each with their department. Each entry carries the person's title, since when they are in the department, and their manager. **HTTP:** `POST /api/iam/departments/listMembers` (requires a credential) · **Browser client:** `client.departments.listMembers()` * **Permission:** `iam:departments:read` on the department. ```ts title="Signature" iam.api.departments.listMembers( credential: CredentialInput, input: { tenantId: string; departmentId: string; includeSubdepartments?: boolean }, ): Promise ``` ## mine [#mine] Your own place in the org chart and, if you head departments, the people you lead. **HTTP:** `POST /api/iam/departments/mine` (requires a credential) · **Browser client:** `client.departments.mine()` * **Permission:** None beyond an ordinary session (or API key) of a person in the organization. * **Audited as:** Not audited; it only reads. * **Errors:** `ACCESS_DENIED` for a service account, an agent, a temporary credential, or another tenant's session. `department` is your department with the path from the top, your title, since when, its head, and its cost center (null without a department). `leads` lists each department you head with its people and those of every department below it (name, email, department, title, and manager), heads first. Use it for a "my team" page that managers can open without `iam:departments:read`. ```ts title="Signature" iam.api.departments.mine( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## ofIdentity [#ofidentity] A person's department with the path from the top, their title, since when, the department's head and cost center; null when they have none. **HTTP:** `POST /api/iam/departments/ofIdentity` (requires a credential) · **Browser client:** `client.departments.ofIdentity()` * **Permission:** `iam:departments:read` on `iam/{identityId}`. ```ts title="Signature" iam.api.departments.ofIdentity( credential: CredentialInput, input: { tenantId: string; identityId: string }, ): Promise<{ costCenter?: string | undefined; head?: DepartmentPerson | undefined; since: number; title?: string | undefined; department: DepartmentRef; path: DepartmentRef[]; } | null> ``` ## suggestBirthright [#suggestbirthright] Roles and groups that most of a department's people already hold by hand, proposed as a ready-made automatic access package whose rule names the department. **HTTP:** `POST /api/iam/departments/suggestBirthright` (requires a credential) · **Browser client:** `client.departments.suggestBirthright()` * **Permission:** `iam:analysis:read` on the tenant. * **Audited as:** Not audited; it only reads. * **Errors:** `INVALID_INPUT` for `minShare` outside 0.5-1; `NOT_FOUND` for an unknown `departmentId`. A department's people are exactly who a rule naming it would match: active people placed in it or in a department below it. Only plain grants count (standing, permanent role bindings made to the person and permanent memberships of ordinary groups, none from an access package), an item must be held by at least `minShare` (default 0.8) of at least `minPeople` (default 3) people, and nothing is suggested twice: not what is suggested for a department above, not what an automatic package naming the department (or one above) grants, and not what most of the department already receives from any automatic package. Each suggestion carries the shares, `wouldGrant` (people who would gain something), `existingPackages`, and `package`, ready for [`packages.create`](/docs/reference/api/packages#create): ```ts const [suggestion] = await iam.api.departments.suggestBirthright(credential, { tenantId, departmentId: engineeringId, }); if (suggestion) await iam.api.packages.create(credential, { tenantId, ...suggestion.package }); ``` ```ts title="Signature" iam.api.departments.suggestBirthright( credential: CredentialInput, input: { tenantId: string; departmentId?: string; minShare?: number; minPeople?: number; }, ): Promise ``` ## syncManagers [#syncmanagers] Makes department heads the managers of their departments' people (see above). Without `overwrite` only people without a manager change; `departmentId` limits the run to one department and those below it; `dryRun` reports only. Returns the changes with names, how many kept another manager, and how many had no head above them. Never creates a cycle. **HTTP:** `POST /api/iam/departments/syncManagers` (requires a credential) · **Browser client:** `client.departments.syncManagers()` * **Permission:** `iam:identities:update` on the tenant (or the department) and `iam:departments:read`. * **Audited as:** `iam:identities:update` and `department:sync-managers`. ```ts title="Signature" iam.api.departments.syncManagers( credential: CredentialInput, input: { tenantId: string; departmentId?: string; overwrite?: boolean; dryRun?: boolean; }, ): Promise ``` ## tree [#tree] The org chart: top-level departments with their sub-departments, heads, and member counts. **HTTP:** `POST /api/iam/departments/tree` (requires a credential) · **Browser client:** `client.departments.tree()` * **Permission:** `iam:departments:read` on the tenant. ```ts title="Signature" iam.api.departments.tree( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## unassign [#unassign] Takes a person out of their department. **HTTP:** `POST /api/iam/departments/unassign` (requires a credential) · **Browser client:** `client.departments.unassign()` * **Permission:** `iam:departments:manage` on `iam/{identityId}`. * **Audited as:** `iam:departments:manage` and `department:unassign`. * **Errors:** `NOT_FOUND` when the person has no department. ```ts title="Signature" iam.api.departments.unassign( credential: CredentialInput, input: { tenantId: string; identityId: string }, ): Promise<{ deleted: true }> ``` ## update [#update] Renames, re-codes, moves (`parentId`, null for top level), or changes the head, cost center, or description of a department; null (or an empty string) clears an optional field. **HTTP:** `POST /api/iam/departments/update` (requires a credential) · **Browser client:** `client.departments.update()` * **Permission:** `iam:departments:manage` on the department. * **Audited as:** `iam:departments:manage` and `department:update` (`fields`). * **Errors:** `INVALID_INPUT` when moving under itself or a department below it, or past twenty levels; `CONFLICT` for a taken name or code. ```ts title="Signature" iam.api.departments.update( credential: CredentialInput, input: DepartmentUpdate, ): Promise ``` # domains (/docs/reference/api/domains) > Verified email domains let an organization prove it owns a domain such as acme.com, so your sign-in page can find the right tenant from a work email address. Verified email domains let an organization prove it owns a domain such as `acme.com`, so your sign-in page can find the right tenant from a work email address. People do not know tenant IDs, and every sign-in names a tenant, so "use your work email" needs a trustworthy mapping from domain to organization. This group creates that mapping and answers the public lookup. It is step one of [enterprise onboarding](/docs/federation/enterprise-onboarding). ## How domain verification works [#how-domain-verification-works] 1. An administrator claims a domain with [`add`](#add). The claim starts as `pending` and comes with a DNS TXT record to publish: the name `_better-iam-challenge.{domain}` with the value `better-iam-verification={token}`. 2. After publishing the record, the administrator calls [`verify`](#verify). The server looks the record up and, when the value matches, marks the domain `verified`. 3. From then on, the public [`discover`](#discover) call maps email addresses at that domain to the organization. A verified domain belongs to exactly one tenant. Several tenants may hold pending claims to the same domain, but only the first to verify owns it; the others then fail with `DOMAIN_TAKEN`. Consumer mailbox providers (Gmail, Outlook, iCloud, and similar) can never be claimed, because their addresses belong to individuals, not one organization. Deployment options under `domains` change the TXT label (`recordName`), replace the blocked list (`blockedDomains`), or inject the DNS resolver (`resolveTxt`), for example to use DNS over HTTPS. Domains are normalized before use: lowercase, no trailing dot, and at least two labels with an alphabetic top-level domain. Claims are removed when the tenant is purged. | Method | What it does | Access | | ----------------------- | --------------------------------------------------------------------------------------------------------- | ---------- | | [`add`](#add) | Claims a domain for the tenant and returns the TXT record the organization must publish to prove control. | Credential | | [`delete`](#delete) | Releases a domain claim; a verified domain stops resolving to the tenant immediately. | Credential | | [`discover`](#discover) | Finds the organization that verified an email address's domain, with the sign-in rules it enforces. | Public | | [`list`](#list) | Lists the tenant's claimed domains, newest first, with their status and the TXT record each one needs. | Credential | | [`verify`](#verify) | Looks up the domain's TXT record and marks the claim verified when the record matches. | Credential | ## add [#add] Claims a domain for the tenant and returns the TXT record the organization must publish to prove control. **HTTP:** `POST /api/iam/domains/add` (requires a credential) · **Browser client:** `client.domains.add()` * **Permission:** `iam:domains:create` on `iam/domains/{domain}`. * **Audited as:** `iam:domains:create`. * **Errors:** `INVALID_INPUT` when the value is not a domain such as `example.com`; `DOMAIN_NOT_ALLOWED` for a shared mailbox provider on the blocked list; `CONFLICT` when this tenant already claimed the domain; `DOMAIN_TAKEN` when another tenant has verified it. The result carries `dnsRecord` (`type`, `name`, `value`); show it to the administrator exactly as returned. The claim grants nothing until it is verified. ```ts const claimed = await iam.api.domains.add(credential, { tenantId, domain: 'acme.com' }); // Publish claimed.dnsRecord at the DNS provider, then call domains.verify. ``` ```ts title="Signature" iam.api.domains.add( credential: CredentialInput, input: { tenantId: string; domain: string }, ): Promise<{ id: string; tenantId: string; domain: string; status: 'pending' | 'verified'; createdAt: number; createdBy: string; verifiedAt: number | undefined; lastCheckedAt: number | undefined; dnsRecord: { type: 'TXT'; name: string; value: string }; }> ``` ## delete [#delete] Releases a domain claim; a verified domain stops resolving to the tenant immediately. **HTTP:** `POST /api/iam/domains/delete` (requires a credential) · **Browser client:** `client.domains.delete()` * **Permission:** `iam:domains:delete` on `iam/domains/{domain}`. * **Audited as:** `iam:domains:delete`. * **Errors:** `NOT_FOUND` when the claim is not in this tenant. Releasing a verified domain frees it, so another organization can then claim and verify it. ```ts title="Signature" iam.api.domains.delete( credential: CredentialInput, input: { tenantId: string; domainId: string }, ): Promise<{ deleted: boolean }> ``` ## discover [#discover] Finds the organization that verified an email address's domain, with the sign-in rules it enforces. **HTTP:** `POST /api/iam/domains/discover` (no credential) · **Browser client:** `client.domains.discover()` * **Permission:** None: public. No credential is needed. * **Errors:** `INVALID_INPUT` when `email` has no `@` or the domain is malformed; `NOT_FOUND` when no active organization verified the domain; `WRONG_REGION` (421) when another region serves the organization. This is home-realm discovery for a login screen: the person types their email, you call `discover`, and you send them to the right tenant with the right method. The answer carries the tenant's `tenantId`, `name`, `type`, alias (`slug`, when set), `allowedMethods` (`null` means every method the deployment enables), and `requireMfa`, plus its home `region` and `signInUrl` when organization addresses or regions are configured. Pass either `email` or `domain`. In a multi-region deployment, an organization homed elsewhere answers `WRONG_REGION` with its sign-in URL there, so a global sign-in page can send the person on. Unknown, pending, released, and inactive domains, and tenants under an inactive ancestor, all answer the same `NOT_FOUND`, so the call does not reveal which of these applies. The result is public discovery data by design; apply ingress rate limits as you would for [`tenants.lookup`](/docs/reference/api/tenants#lookup). ```ts const org = await client.domains.discover({ email: 'alice@acme.com' }); await client.auth.signIn({ tenantId: org.tenantId, email: 'alice@acme.com', password }); ``` ```ts title="Signature" iam.api.domains.discover( input: { email?: string; domain?: string }, ): Promise ``` ## list [#list] Lists the tenant's claimed domains, newest first, with their status and the TXT record each one needs. **HTTP:** `POST /api/iam/domains/list` (requires a credential) · **Browser client:** `client.domains.list()` * **Permission:** `iam:domains:read` on `iam/domains/*`. * **Audited as:** `iam:domains:read`. Each entry shows `status` (`pending` or `verified`), when it was verified, and when the DNS record was last checked (`lastCheckedAt`), which helps an administrator see whether a failed verification was retried. ```ts title="Signature" iam.api.domains.list( credential: CredentialInput, input: { tenantId: string }, ): Promise<{ id: string; tenantId: string; domain: string; status: 'pending' | 'verified'; createdAt: number; createdBy: string; verifiedAt: number | undefined; lastCheckedAt: number | undefined; dnsRecord: { type: 'TXT'; name: string; value: string }; }[]> ``` ## verify [#verify] Looks up the domain's TXT record and marks the claim verified when the record matches. **HTTP:** `POST /api/iam/domains/verify` (requires a credential) · **Browser client:** `client.domains.verify()` * **Permission:** `iam:domains:update` on `iam/domains/{domain}`. * **Audited as:** `iam:domains:update`. * **Errors:** `NOT_FOUND` when the claim is not in this tenant; `DOMAIN_TAKEN` when another tenant verified the domain first. A missing or not-yet-visible record is not an error: the call returns `verified: false`, records `lastCheckedAt`, and leaves the claim pending, so you can retry after DNS propagates. The DNS lookup runs before the database transaction opens, and a failed lookup counts as "not found". Verifying a domain that is already verified returns `verified: true` without another lookup. ```ts title="Signature" iam.api.domains.verify( credential: CredentialInput, input: { tenantId: string; domainId: string }, ): Promise<{ verified: boolean; domain: { id: string; tenantId: string; domain: string; status: 'pending' | 'verified'; createdAt: number; createdBy: string; verifiedAt: number | undefined; lastCheckedAt: number | undefined; dnsRecord: { type: 'TXT'; name: string; value: string }; }; }> ``` # features (/docs/reference/api/features) > Feature flags turn product features on and off per tenant without a deploy. Feature flags turn product features on and off per tenant without a deploy. Flags the root tenant defines are platform flags and reach every organization and project; flags an organization or project defines reach only its own subtree. A key belongs to the tenant closest to the root, so a tenant can never shadow a platform flag. Every flag is a boolean, read by applications through `evaluate`, by server code through `iam.features` (`evaluate`, `values`, `isEnabled`, no credential), and by policies through the `tenant.features` condition key. The repository guide is `docs/feature-flags.md`. ## How a value is decided [#how-a-value-is-decided] For a given tenant, the first rule that applies decides: 1. The **kill switch** (`killSwitch: true`): off everywhere. 2. The **closest target or override** on the way from the tenant up to (not including) the defining tenant. A target is a value the flag's managers pin for a tenant below them (`setTarget`); an override is a tenant's own choice for a `tenantOverridable` flag (`setOverride`). On the same tenant the override wins; a `locked` target silences overrides at its tenant and below. Targets can lapse (`expiresAt`). 3. The **rollout** (`rolloutPercentage`, 0 to 100): a stable share of the branches directly below the defining tenant gets `true`, so an organization and its projects land on the same side. Raising it only adds tenants. 4. The **default** (`defaultValue`). Resources are `iam/features/{key}` (and `iam/features` for `list`), so a policy can delegate one flag or a family, such as `iam:features:override` on `iam/features/beta-*`. | Method | What it does | Access | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`create`](#create) | Defines a flag in the tenant: a platform flag on the root tenant, a flag for the tenant's subtree elsewhere. | Credential | | [`delete`](#delete) | Deletes a flag together with every target and override set for it. | Credential | | [`evaluate`](#evaluate) | Returns `{ tenantId, flags: { key: boolean } }` for the flags that reach the tenant, for applications deciding what to show. | Credential | | [`list`](#list) | The flags that reach the tenant, sorted by key, each with its value for the tenant and why. | Credential | | [`listTargets`](#listtargets) | Every target and override below the defining tenant for one flag, newest first. | Credential | | [`setOverride`](#setoverride) | Records a tenant's own choice for a flag an ancestor defines with `tenantOverridable: true`. | Credential | | [`setTarget`](#settarget) | Pins a flag's value for a tenant below the defining tenant (`tenantId`), and for that tenant's descendants. | Credential | | [`update`](#update) | Changes a flag's settings. Fields left out keep their value. | Credential | ## create [#create] Defines a flag in the tenant: a platform flag on the root tenant, a flag for the tenant's subtree elsewhere. **HTTP:** `POST /api/iam/features/create` (requires a credential) · **Browser client:** `client.features.create()` * **Permission:** `iam:features:manage` on `iam/features/{key}`. * **Audited as:** `feature:create`, with the settings. * **Errors:** `CONFLICT` (409) when the tenant or one of its ancestors already defines the key; `LIMIT_EXCEEDED` (409) past 200 flags in the tenant; `INVALID_INPUT` for a malformed key, an unknown field, `rolloutPercentage` with `defaultValue: true`, or an `internal` flag that is also `tenantOverridable`; `INVALID_TRANSITION` in a deleted tenant. Keys are lowercase letters and digits joined by `-`, `_`, or `.`, starting with a letter, at most 64 characters. Settings default to off, not overridable, no kill switch, not internal. `internal` flags are evaluated only by server code and policies and are hidden from tenants below the defining one. ```ts await iam.api.features.create(rootCredential, { tenantId: rootTenantId, key: 'new-billing', description: 'The redesigned billing pages', tenantOverridable: true, }); ``` ```ts title="Signature" iam.api.features.create( credential: CredentialInput, input: { tenantId: string; key: string; description?: string; defaultValue?: boolean; rolloutPercentage?: number; tenantOverridable?: boolean; killSwitch?: boolean; internal?: boolean; }, ): Promise ``` ## delete [#delete] Deletes a flag together with every target and override set for it. **HTTP:** `POST /api/iam/features/delete` (requires a credential) · **Browser client:** `client.features.delete()` * **Permission:** `iam:features:manage` on `iam/features/{key}`. * **Audited as:** `feature:delete`, with `removedTargets`. * **Errors:** `NOT_FOUND` when the tenant does not define the key. Code that still asks for the key gets `false` (reason `UNKNOWN`). The result is `{ success: true, removedTargets }`. ```ts title="Signature" iam.api.features.delete( credential: CredentialInput, input: { tenantId: string; key: string }, ): Promise<{ success: true; removedTargets: number }> ``` ## evaluate [#evaluate] Returns `{ tenantId, flags: { key: boolean } }` for the flags that reach the tenant, for applications deciding what to show. **HTTP:** `POST /api/iam/features/evaluate` (requires a credential) · **Browser client:** `client.features.evaluate()` * **Permission:** None beyond a session (user, API key, role, or session token) of the tenant; root administrators may evaluate any tenant. * **Audited as:** not audited. * **Errors:** `ACCESS_DENIED` (403) for a session of another tenant; `INVALID_INPUT` for a malformed key or more than 100 keys. Internal flags of ancestors are left out. `keys` limits the answer to those flags, and a requested key that no flag defines comes back `false`. The React hooks `useFeatureFlags` and `useFeatureFlag` call it. ```ts const { flags } = await client.features.evaluate({ tenantId }); if (flags['new-billing']) showNewBilling(); ``` ```ts title="Signature" iam.api.features.evaluate( credential: CredentialInput, input: { tenantId: string; keys?: string[] }, ): Promise<{ tenantId: string; flags: Record }> ``` ## list [#list] The flags that reach the tenant, sorted by key, each with its value for the tenant and why. **HTTP:** `POST /api/iam/features/list` (requires a credential) · **Browser client:** `client.features.list()` * **Permission:** `iam:features:read` on `iam/features`. * **Audited as:** `iam:features:read`. Each entry has `evaluation` (`value`, `reason` of `KILL_SWITCH`, `TARGET`, `OVERRIDE`, `ROLLOUT`, or `DEFAULT`, `decidedBy`, `expiresAt`, `locked`, and `overridable`), the tenant's own `override`, and the `target` pinned for it. Flags the tenant defines also carry `definition` with their settings. Ancestors' internal flags are left out, except for root administrators, who see every flag with its definition. `shadowed` lists flags this tenant defines whose key an ancestor also defines, so the ancestor's flag applies. Rename or delete them. ```ts title="Signature" iam.api.features.list( credential: CredentialInput, input: { tenantId: string }, ): Promise<{ tenantId: string; flags: FeatureFlagView[]; shadowed: FeatureFlagDefinition[]; }> ``` ## listTargets [#listtargets] Every target and override below the defining tenant for one flag, newest first. **HTTP:** `POST /api/iam/features/listTargets` (requires a credential) · **Browser client:** `client.features.listTargets()` * **Permission:** `iam:features:read` on `iam/features/{key}`, in the defining tenant. * **Audited as:** `iam:features:read`. * **Errors:** `NOT_FOUND` when the tenant does not define the key. Each entry names the tenant (`tenantName`, `tenantStatus`), the `source` (`target` or `override`), the value, `locked`, `expiresAt`, the `note`, and `active`: false once a target has lapsed or an override is no longer allowed. Only the defining tenant's managers see notes. ```ts title="Signature" iam.api.features.listTargets( credential: CredentialInput, input: { tenantId: string; key: string }, ): Promise ``` ## setOverride [#setoverride] Records a tenant's own choice for a flag an ancestor defines with `tenantOverridable: true`. **HTTP:** `POST /api/iam/features/setOverride` (requires a credential) · **Browser client:** `client.features.setOverride()` * **Permission:** `iam:features:override` on `iam/features/{key}`, in the tenant. * **Audited as:** `feature:override`, with the value (`null` when withdrawn). * **Errors:** `FEATURE_LOCKED` (409) when the flag does not allow overrides or a locked target covers the tenant; `NOT_FOUND` for a key no flag defines or an ancestor's internal flag; `INVALID_INPUT` when the tenant defines the flag itself (change it with `update`). The choice applies to the tenant and its descendants unless something closer decides. `value: null` withdraws it, which is always allowed. The result is the tenant's new evaluation. ```ts title="Signature" iam.api.features.setOverride( credential: CredentialInput, input: { tenantId: string; key: string; value: boolean | null }, ): Promise ``` ## setTarget [#settarget] Pins a flag's value for a tenant below the defining tenant (`tenantId`), and for that tenant's descendants. **HTTP:** `POST /api/iam/features/setTarget` (requires a credential) · **Browser client:** `client.features.setTarget()` * **Permission:** `iam:features:manage` on `iam/features/{key}`, in the defining tenant. * **Audited as:** `feature:target`, recorded in the defining tenant with `targetTenantId`, `value`, `locked`, and `expiresAt`. * **Errors:** `INVALID_INPUT` when `targetTenantId` is not below the defining tenant (use `defaultValue` for the tenant itself) or `expiresAt` is not in the next ten years; `NOT_FOUND` when the tenant does not define the key. `locked: true` keeps the tenant and everything below it from overriding. `expiresAt` makes the target lapse by itself, for a trial or a temporary block. `note` is visible only to the defining tenant's managers. `value: null` removes the target. ```ts await iam.api.features.setTarget(rootCredential, { tenantId: rootTenantId, key: 'fast-search', targetTenantId: acmeId, value: true, expiresAt: Date.now() + 14 * 86_400_000, note: 'Design partner trial', }); ``` ```ts title="Signature" iam.api.features.setTarget( credential: CredentialInput, input: { tenantId: string; key: string; targetTenantId: string; value: boolean | null; locked?: boolean; expiresAt?: number; note?: string; }, ): Promise ``` ## update [#update] Changes a flag's settings. Fields left out keep their value. **HTTP:** `POST /api/iam/features/update` (requires a credential) · **Browser client:** `client.features.update()` * **Permission:** `iam:features:manage` on `iam/features/{key}`. * **Audited as:** `feature:update`, with the settings `before` and `after`. * **Errors:** `NOT_FOUND` when the tenant does not define the key; `INVALID_INPUT` as for `create`. `null` clears `description` or `rolloutPercentage`. Turning `killSwitch` on switches the flag off for every tenant at once, and turning it off restores the stored targets, overrides, and rollout. Turning `tenantOverridable` off keeps existing overrides but ignores them. ```ts title="Signature" iam.api.features.update( credential: CredentialInput, input: { tenantId: string; key: string; description?: string | null; defaultValue?: boolean; rolloutPercentage?: number | null; tenantOverridable?: boolean; killSwitch?: boolean; internal?: boolean; }, ): Promise ``` # groups (/docs/reference/api/groups) > Groups let you grant access to many people at once. Groups let you grant access to many people at once. Instead of binding the same roles to every member of the finance team, you bind them once to a "Finance" group and manage who is in it; everyone in the group receives every role bound to it, and leaving the group removes that access immediately. Memberships can be temporary, which suits contractors, on-call rotations, and project teams. ## How group access works [#how-group-access-works] A group has no permissions of its own. Access comes from [role bindings](/docs/guides/authorization/roles) whose subject is the group, so changing membership is, in effect, granting or revoking those roles. That is why adding or removing a member requires authority over each of the group's bindings, not just permission to edit the group: someone who could not grant a role directly cannot grant it by putting a person in a group. Temporary memberships carry an `expiresAt` (epoch milliseconds). They stop counting the moment they lapse, are left out of `listMembers`, and are removed later by the purge job. Memberships created by an [access package](/docs/guides/privileged-access/access-packages) are tagged with the package's assignment; editing one by hand takes it over, so revoking the package no longer removes it. | Method | What it does | Access | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------- | | [`addMember`](#addmember) | Adds a person to a group so they receive every role bound to the group, optionally until a given time. | Credential | | [`addMembers`](#addmembers) | Adds up to 100 people to a group in one transaction, all with the same optional expiry. | Credential | | [`create`](#create) | Creates a group in the tenant. | Credential | | [`delete`](#delete) | Deletes a group together with its memberships, its role bindings, their activations, and its relationship tuples. | Credential | | [`get`](#get) | Returns one group by id. | Credential | | [`list`](#list) | Lists every group in the tenant. | Credential | | [`listMembers`](#listmembers) | Lists the current members of a group, with `membershipExpiresAt` on temporary memberships. | Credential | | [`removeMember`](#removemember) | Removes a person from a group, ending the access the group's roles gave them. | Credential | | [`update`](#update) | Renames a group or changes its description. | Credential | | [`updateMember`](#updatemember) | Extends, shortens, or clears the expiry of an existing membership. | Credential | ## addMember [#addmember] Adds a person to a group so they receive every role bound to the group, optionally until a given time. **HTTP:** `POST /api/iam/groups/addMember` (requires a credential) · **Browser client:** `client.groups.addMember()` * **Permission:** `iam:groups:update` on the group, plus grant authority for each of the group's role bindings. * **Audited as:** `iam:groups:update`. * **Errors:** `CONFLICT` when the person is already a live member; `NOT_FOUND` when the group or person is not in this tenant; `ACCESS_DENIED` without authority over one of the group's bindings; `SOD_CONFLICT` when the membership would give the person a combination of roles a [separation-of-duties rule](/docs/guides/authorization/separation-of-duties) forbids. Pass `expiresAt` to make the membership temporary. Adding someone whose earlier membership has lapsed renews it instead of failing, and the renewed membership no longer belongs to the access package that originally created it. ```ts // Give a contractor the team's access for 30 days. await iam.api.groups.addMember(credential, { tenantId, groupId, identityId, expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, }); ``` ```ts title="Signature" iam.api.groups.addMember( credential: CredentialInput, input: { tenantId: string; groupId: string; identityId: string; expiresAt?: number }, ): Promise ``` ## addMembers [#addmembers] Adds up to 100 people to a group in one transaction, all with the same optional expiry. **HTTP:** `POST /api/iam/groups/addMembers` (requires a credential) · **Browser client:** `client.groups.addMembers()` * **Permission:** `iam:groups:update` on the group, plus grant authority for each of the group's role bindings. * **Audited as:** `iam:groups:update`. * **Errors:** `INVALID_INPUT` when `identityIds` is empty; any error `addMember` can raise for one person (including `SOD_CONFLICT`) rejects the whole batch. Use it for cohort onboarding, such as a new class of employees or everyone joining a project on the same day. Duplicate ids are ignored. Because it is atomic, either everyone is added or no one is. ```ts title="Signature" iam.api.groups.addMembers( credential: CredentialInput, input: { tenantId: string; groupId: string; identityIds: string[]; expiresAt?: number; }, ): Promise<{ members: GroupMember[] }> ``` ## create [#create] Creates a group in the tenant. **HTTP:** `POST /api/iam/groups/create` (requires a credential) · **Browser client:** `client.groups.create()` * **Permission:** `iam:groups:create` on the tenant. * **Audited as:** `iam:groups:create`. * **Errors:** `LIMIT_EXCEEDED` when the tenant's plan limit for groups is reached; `INVALID_INPUT` for an empty name or a description over 512 characters. A new group is empty and grants nothing until you bind roles to it with [`bindings.create`](/docs/reference/api/bindings#create) and add members. ```ts title="Signature" iam.api.groups.create( credential: CredentialInput, input: GroupInput, ): Promise ``` ## delete [#delete] Deletes a group together with its memberships, its role bindings, their activations, and its relationship tuples. **HTTP:** `POST /api/iam/groups/delete` (requires a credential) · **Browser client:** `client.groups.delete()` * **Permission:** `iam:groups:delete` on the group, plus grant authority for each of its role bindings. * **Audited as:** `iam:groups:delete`. * **Errors:** `RESOURCE_IN_USE` (409) when an access package still grants the group or names it in an automatic assignment rule, or when the group approves package requests or eligible-binding activations. The in-use checks exist so deleting a group never silently changes who can approve requests or what a package grants: point those at another group first. ```ts title="Signature" iam.api.groups.delete( credential: CredentialInput, input: { tenantId: string; groupId: string }, ): Promise<{ deleted: boolean }> ``` ## get [#get] Returns one group by id. **HTTP:** `POST /api/iam/groups/get` (requires a credential) · **Browser client:** `client.groups.get()` * **Permission:** `iam:groups:read` on the group. * **Audited as:** `iam:groups:read`. * **Errors:** `NOT_FOUND` when the group is not in this tenant. ```ts title="Signature" iam.api.groups.get( credential: CredentialInput, input: { tenantId: string; groupId: string }, ): Promise ``` ## list [#list] Lists every group in the tenant. **HTTP:** `POST /api/iam/groups/list` (requires a credential) · **Browser client:** `client.groups.list()` * **Permission:** `iam:groups:read` on the tenant. * **Audited as:** `iam:groups:read`. ```ts title="Signature" iam.api.groups.list( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listMembers [#listmembers] Lists the current members of a group, with `membershipExpiresAt` on temporary memberships. **HTTP:** `POST /api/iam/groups/listMembers` (requires a credential) · **Browser client:** `client.groups.listMembers()` * **Permission:** `iam:groups:read` on the group. * **Audited as:** `iam:groups:read`. * **Errors:** `NOT_FOUND` when the group is not in this tenant. Lapsed memberships are left out even before the purge job removes them, so the list always matches who currently receives the group's roles. Members are returned as public identities, without credential material. ```ts title="Signature" iam.api.groups.listMembers( credential: CredentialInput, input: { tenantId: string; groupId: string }, ): Promise<(PublicIdentity & { membershipExpiresAt?: number })[]> ``` ## removeMember [#removemember] Removes a person from a group, ending the access the group's roles gave them. **HTTP:** `POST /api/iam/groups/removeMember` (requires a credential) · **Browser client:** `client.groups.removeMember()` * **Permission:** `iam:groups:update` on the group, plus grant authority for each of the group's role bindings. * **Audited as:** `iam:groups:update`. Any [just-in-time activations](/docs/guides/privileged-access/elevation) the person had of the group's eligible bindings end at the same time, so removing someone from a group cannot leave them elevated. Removing a person who is not a member succeeds and changes nothing. ```ts title="Signature" iam.api.groups.removeMember( credential: CredentialInput, input: { tenantId: string; groupId: string; identityId: string }, ): Promise<{ deleted: boolean }> ``` ## update [#update] Renames a group or changes its description. **HTTP:** `POST /api/iam/groups/update` (requires a credential) · **Browser client:** `client.groups.update()` * **Permission:** `iam:groups:update` on the group. * **Audited as:** `iam:groups:update`. * **Errors:** `INVALID_INPUT` when neither `name` nor `description` is given. ```ts title="Signature" iam.api.groups.update( credential: CredentialInput, input: { tenantId: string; groupId: string; name?: string; description?: string }, ): Promise ``` ## updateMember [#updatemember] Extends, shortens, or clears the expiry of an existing membership. **HTTP:** `POST /api/iam/groups/updateMember` (requires a credential) · **Browser client:** `client.groups.updateMember()` * **Permission:** `iam:groups:update` on the group, plus grant authority for each of the group's role bindings. * **Audited as:** `iam:groups:update`. * **Errors:** `NOT_FOUND` when the person is not a live member of the group. Pass `expiresAt: null` to make a temporary membership permanent. Editing a membership that an access package created takes it over: revoking the package will no longer remove it. ```ts title="Signature" iam.api.groups.updateMember( credential: CredentialInput, input: { tenantId: string; groupId: string; identityId: string; expiresAt: number | null; }, ): Promise ``` # hostnames (/docs/reference/api/hostnames) > Custom hostnames let an organization sign in at an address of its own, such as login.acme.com, instead of the subdomain the deployment gives it (acme.signin.ex… Custom hostnames let an organization sign in at an address of its own, such as `login.acme.com`, instead of the subdomain the deployment gives it (`acme.signin.example.com`). Enterprise customers ask for this so the sign-in page sits on their own domain, which their people recognize and their security team can vouch for. This group claims a hostname, hands back the DNS records that prove control and route traffic, verifies it, makes it the organization's primary address, and releases it. It needs `hosts.customHostnames: true`; see [sign-in addresses and regions](/docs/operations/deployment/hosts-and-regions). ## How a hostname goes live [#how-a-hostname-goes-live] 1. [`add`](#add) claims the hostname and returns two DNS records: a TXT record that proves the organization controls the name, and (when the deployment sets `hosts.cnameTarget`) a CNAME that sends its traffic to the deployment. 2. The organization publishes both records with its DNS provider. 3. [`verify`](#verify) looks the TXT record up. Once it matches, the hostname belongs to the organization and to no other, and requests on it are pinned to the organization, like its subdomain. 4. Optionally, [`setPrimary`](#setprimary) makes it the address sign-in URLs and email links use. Your TLS termination must also serve a certificate for the hostname. With on-demand certificates (Caddy's `ask`, Cloudflare for SaaS, and similar), let `iam.hosts.allowed(hostname)` decide: it is true only for verified hostnames of active organizations. Passkeys are bound to one domain, so they are not offered on a custom hostname outside the deployment's passkey domain; people sign in there with the other methods. | Method | What it does | Access | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`add`](#add) | Claims a hostname for the organization and returns the DNS records that verify and route it. | Credential | | [`delete`](#delete) | Releases a hostname; a verified one stops resolving to the organization at once. | Credential | | [`list`](#list) | Lists the organization's claimed hostnames, newest first, with their status and DNS records. | Credential | | [`setPrimary`](#setprimary) | Makes a verified hostname the organization's canonical sign-in address, or goes back to its subdomain. | Credential | | [`verify`](#verify) | Looks up the hostname's TXT record and, when it matches, marks the hostname verified so it starts resolving to the organization. | Credential | ## add [#add] Claims a hostname for the organization and returns the DNS records that verify and route it. **HTTP:** `POST /api/iam/hostnames/add` (requires a credential) · **Browser client:** `client.hostnames.add()` * **Permission:** `iam:hostnames:create` on the tenant. * **Audited as:** `iam:hostnames:create`, resource `hostnames/{hostname}`. * **Errors:** `FEATURE_DISABLED` unless `hosts.customHostnames` is on; `INVALID_INPUT` for a malformed name; `HOSTNAME_NOT_ALLOWED` for a name the deployment uses itself (its base URL, a trusted origin, or its organization subdomain space); `CONFLICT` (409) when the organization already claimed it; `HOSTNAME_TAKEN` (409) when another organization verified it; `LIMIT_EXCEEDED` past 20 hostnames per organization. The name is lowercased and a trailing dot removed. The claim stays `pending`, and resolves to nothing, until [`verify`](#verify) sees the TXT record: `_better-iam-challenge.{hostname}` (the label follows `domains.recordName`) with the value `better-iam-hostname={token}`. ```ts const claimed = await iam.api.hostnames.add(credential, { tenantId, hostname: 'login.acme.com' }); // claimed.dnsRecords.verification: { type: 'TXT', name: '_better-iam-challenge.login.acme.com', value: '…' } // claimed.dnsRecords.routing: { type: 'CNAME', name: 'login.acme.com', value: 'custom.signin.example.com' } ``` ```ts title="Signature" iam.api.hostnames.add( credential: CredentialInput, input: { tenantId: string; hostname: string }, ): Promise ``` ## delete [#delete] Releases a hostname; a verified one stops resolving to the organization at once. **HTTP:** `POST /api/iam/hostnames/delete` (requires a credential) · **Browser client:** `client.hostnames.delete()` * **Permission:** `iam:hostnames:delete` on the tenant. * **Audited as:** `iam:hostnames:delete`. * **Errors:** `NOT_FOUND` when the hostname is not the organization's. If it was the primary address, sign-in URLs fall back to the organization's subdomain. Remove the DNS records afterwards, and revoke its TLS certificate if your certificate automation does not. ```ts title="Signature" iam.api.hostnames.delete( credential: CredentialInput, input: { tenantId: string; hostnameId: string }, ): Promise<{ deleted: boolean }> ``` ## list [#list] Lists the organization's claimed hostnames, newest first, with their status and DNS records. **HTTP:** `POST /api/iam/hostnames/list` (requires a credential) · **Browser client:** `client.hostnames.list()` * **Permission:** `iam:hostnames:read` on the tenant. * **Audited as:** `iam:hostnames:read`. Each entry shows `status` (`pending` or `verified`), whether it is `primary`, its sign-in `url`, and the records to publish, so a settings page can show setup instructions until verification succeeds. ```ts title="Signature" iam.api.hostnames.list( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## setPrimary [#setprimary] Makes a verified hostname the organization's canonical sign-in address, or goes back to its subdomain. **HTTP:** `POST /api/iam/hostnames/setPrimary` (requires a credential) · **Browser client:** `client.hostnames.setPrimary()` * **Permission:** `iam:hostnames:update` on the tenant. * **Audited as:** `iam:hostnames:update`. * **Errors:** `INVALID_INPUT` when the hostname is not verified yet; `NOT_FOUND` when it is not the organization's. Sign-in URLs from [`tenants.lookup`](/docs/reference/api/tenants#lookup), `iam.hosts.signInUrl`, and the `signInUrl` of every email the organization's people receive use the primary hostname. `hostnameId: null` clears it. The result has the new primary hostname (or `null`) and the organization's `signInUrl`. ```ts await iam.api.hostnames.setPrimary(credential, { tenantId, hostnameId: claimed.id }); ``` ```ts title="Signature" iam.api.hostnames.setPrimary( credential: CredentialInput, input: { tenantId: string; hostnameId: string | null }, ): Promise<{ primary: PublicHostname | null; signInUrl: string | null }> ``` ## verify [#verify] Looks up the hostname's TXT record and, when it matches, marks the hostname verified so it starts resolving to the organization. **HTTP:** `POST /api/iam/hostnames/verify` (requires a credential) · **Browser client:** `client.hostnames.verify()` * **Permission:** `iam:hostnames:update` on the tenant. * **Audited as:** `iam:hostnames:update`. * **Errors:** `FEATURE_DISABLED` unless custom hostnames are on; `HOSTNAME_TAKEN` (409) when another organization verified it first; `HOSTNAME_NOT_ALLOWED` when the deployment's own addresses changed to include it; `NOT_FOUND` when it is not the organization's. DNS is queried before the transaction opens, through `domains.resolveTxt` when you configure one (for example DNS over HTTPS). While the record is not visible yet, the call succeeds with `verified: false` and records `lastCheckedAt`, so a settings page can poll it. A verified hostname stays verified; to re-prove control, delete it and claim it again. ```ts title="Signature" iam.api.hostnames.verify( credential: CredentialInput, input: { tenantId: string; hostnameId: string }, ): Promise<{ verified: boolean; hostname: PublicHostname }> ``` # identities (/docs/reference/api/identities) > Identities are the people and service accounts that sign in to a tenant, and this group manages them from invitation to offboarding. Identities are the people and service accounts that sign in to a tenant, and this group manages them from invitation to offboarding. Every identity belongs to exactly one tenant, and its email is unique only within that tenant: another tenant may hold a separate identity with the same address. The group creates people directly or through email invitations, reads their sessions, groups, and effective roles, changes their profile, status, and ownership, answers data-subject requests, and removes access cleanly when someone leaves. Service accounts (`kind: 'service'`) live in the same directory. You create them with [`serviceAccounts.create`](/docs/reference/api/service-accounts#create), but reading, listing, disabling, offboarding, and deleting them go through these methods and the same `iam:identities:*` actions. See [tenants and identities](/docs/guides/concepts/tenants-and-identities) for the model. ## Invitations [#invitations] An invitation lets an administrator decide what a new member receives, while the member proves they control the address and chooses their own password. [`invite`](#invite) stores the email, optional roles and groups, and only a hash of a single-use token, then queues a `member-invitation` email through the [delivery outbox](/docs/operations/jobs). The token is sealed inside that message, so neither the inviter nor the API response ever sees it. Your invitation page reads the token from the link and calls the public [`acceptInvitation`](#acceptinvitation). * **Lifetime.** An invitation lasts `onboarding.invitationLifetimeMs` (24 hours by default). [`resendInvitation`](#resendinvitation) issues a new token and lifetime and the earlier link stops working; [`revokeInvitation`](#revokeinvitation) cancels it. Accepted, revoked, and expired invitations fail with `INVITATION_INVALID`. * **Authority.** Roles and groups are checked when you invite and applied when the person accepts, as bindings under the grant authority you held at invite time. That authority is re-validated at acceptance: if it was revoked in the meantime, the invitation can no longer be accepted. * **Delivery.** Invitations need an email delivery callback (`authentication.sendEmail`); without one, `invite` and `resendInvitation` fail with `DELIVERY_REQUIRED`. * **Limits.** The plan's member limit is checked at acceptance, not when you invite, so an invitation can fail with `LIMIT_EXCEEDED` if the tenant filled up in the meantime. Organization owners are invited differently: [`tenants.create`](/docs/reference/api/tenants#create) sends an owner invitation for a new tenant. ## Disabling, offboarding, and deleting [#disabling-offboarding-and-deleting] Four calls take access away, from lightest to heaviest: | Call | Takes away | Keeps | Typical use | | ------------------------------------ | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------- | | [`revokeSessions`](#revokesessions) | Sessions, API keys, remembered devices | The account and all its access | A lost device or a suspected stolen token | | [`setStatus`](#setstatus) `disabled` | The ability to sign in or authenticate; sessions and keys end | Roles, groups, and attributes, which apply again when re-enabled | Leave of absence, an investigation | | [`offboard`](#offboard) | Every grant: bindings, memberships, packages, activations, relationships, authorities, sessions, keys | A disabled record for retention | Someone leaves | | [`delete`](#delete) | The record itself, with credentials and factors | A tombstone, so audit records still resolve | After your retention period | An identity can also carry an `expiresAt` for contractors and temporary accounts. From that moment its credentials are refused, and the retention worker (`purgeDeleted`) disables it, ends its sessions and activations, and records `identity:expire`. An expired identity cannot be re-enabled until you extend or clear the date with [`update`](#update), so nobody quietly turns a contractor back on. See [time-bound identities](/docs/guides/privileged-access/lifecycle#time-bound-identities). Two kinds of account are protected throughout. The last active owner of a tenant cannot be disabled, offboarded, deleted, or demoted (`LAST_OWNER`), and the last active root administrator cannot be disabled, offboarded, or deleted (`LAST_ROOT_ADMIN`). A root administrator's status, sessions, expiry, sign-in address, and password can be changed only by a root administrator. | Method | What it does | Access | | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`acceptInvitation`](#acceptinvitation) | Redeems a member invitation: creates the person's account with the invited email, applies the invitation's roles and groups, and signs them in. | Public | | [`create`](#create) | Creates a person in the tenant, optionally with a password, a manager, and a scheduled deactivation date. | Credential | | [`createMany`](#createmany) | Creates up to 100 people in one transaction, each with optional attributes, roles, groups, password, and expiry. | Credential | | [`delete`](#delete) | Removes a person or service account for good, leaving a tombstone so audit records still name who acted. | Credential | | [`export`](#export) | Returns everything the tenant stores about one identity as JSON, to answer a data-subject access request. | Credential | | [`get`](#get) | Returns one identity by ID, including the tombstone of a deleted identity. | Credential | | [`impersonate`](#impersonate) | Opens a short-lived "view as" session as a member, for support and troubleshooting, and returns its token. | Credential | | [`invite`](#invite) | Invites a person to the tenant by email, with roles and groups they receive when they accept. | Credential | | [`list`](#list) | Lists the tenant's people and service accounts, with filters and paging. | Credential | | [`listBindings`](#listbindings) | Lists an identity's role bindings, direct and through its groups, with their activation and window state. | Credential | | [`listGroups`](#listgroups) | Lists the groups an identity currently belongs to, with `membershipExpiresAt` on temporary memberships. | Credential | | [`listInvitations`](#listinvitations) | Lists every member invitation of the tenant, pending or not, without tokens. | Credential | | [`listReports`](#listreports) | Lists the active people whose manager is this identity, by name. | Credential | | [`listSessions`](#listsessions) | Lists an identity's unexpired sessions and API keys, most recently used first, without token hashes. | Credential | | [`offboard`](#offboard) | Disables an identity and removes everything that gave it access in one transaction, handing what it owned to a successor. | Credential | | [`requestPasswordReset`](#requestpasswordreset) | Emails a member a password-reset link on an administrator's behalf. | Credential | | [`resendInvitation`](#resendinvitation) | Sends a member invitation again with a new token and a fresh lifetime; the earlier link stops working. | Credential | | [`revokeInvitation`](#revokeinvitation) | Cancels a member invitation so its link can no longer be used. | Credential | | [`revokeSessions`](#revokesessions) | Ends every session and API key of an identity without disabling it. | Credential | | [`setBoundary`](#setboundary) | Sets a root-controlled permissions boundary on one identity, capping what it may do in the tenant whatever its roles grant. | Credential | | [`setOwner`](#setowner) | Makes a member an owner of the tenant, or removes their ownership. | Credential | | [`setStatus`](#setstatus) | Disables an identity or re-enables it. | Credential | | [`unlock`](#unlock) | Clears the rate-limit counters that lock a person out of sign-in, recovery, and MFA after too many attempts. | Credential | | [`update`](#update) | Changes an identity's name, declared attributes, email, manager, or scheduled deactivation. | Credential | ## acceptInvitation [#acceptinvitation] Redeems a member invitation: creates the person's account with the invited email, applies the invitation's roles and groups, and signs them in. **HTTP:** `POST /api/iam/identities/acceptInvitation` (no credential) · **Browser client:** `client.identities.acceptInvitation()` * **Permission:** None: public. The token from the invitation email is the proof. * **Audited as:** `identity:invitation:accept`, with the new member as the actor and the inviter, roles, and groups in the metadata. * **Errors:** `INVITATION_INVALID` when the token is unknown, already used, revoked, or expired, or the inviter's grant authority was revoked; `TENANT_UNAVAILABLE` when the tenant or one of its ancestors is not active; `INVALID_INPUT` when neither the call nor the invitation gives a name; `IDENTITY_EXISTS` when an account with that email was created in the meantime; `LIMIT_EXCEEDED` at the tenant's member limit; `WEAK_PASSWORD` or `BREACHED_PASSWORD` when the password fails the password rules; `NOT_FOUND` when one of the invitation's roles or groups was deleted since; `SOD_CONFLICT` when the invitation's roles together break a [separation-of-duties rule](/docs/guides/authorization/separation-of-duties). The email counts as verified, because following the link proved control of the address. `name` overrides the name the inviter suggested. The result is the new public identity plus either `{ token, session }` or an MFA challenge (`mfaRequired: true`) when the tenant requires MFA; continue with the [MFA flow](/docs/guides/authentication/mfa#completing-a-challenge). Over HTTP, a response that issues a session also sets the session cookie. Everything happens in one transaction, so a failure leaves the invitation usable. ```ts const result = await client.identities.acceptInvitation({ tenantId: params.tenant, token: params.token, password: form.password, }); if ('mfaRequired' in result) { // enroll or verify the second factor, then continue } ``` ```ts title="Signature" iam.api.identities.acceptInvitation( input: { tenantId: string; token: string; name?: string; password: string }, ): Promise ``` ## create [#create] Creates a person in the tenant, optionally with a password, a manager, and a scheduled deactivation date. **HTTP:** `POST /api/iam/identities/create` (requires a credential) · **Browser client:** `client.identities.create()` * **Permission:** `iam:identities:create` on the tenant. * **Audited as:** `iam:identities:create`. * **Errors:** `IDENTITY_EXISTS` (409) when the email is already used in this tenant; `LIMIT_EXCEEDED` at the tenant's member limit; `WEAK_PASSWORD` or `BREACHED_PASSWORD` for a password the rules refuse; `INVALID_INPUT` for an `expiresAt` that is not in the future or is more than ten years ahead, or a manager who is not active; `NOT_FOUND` when the manager is not in this tenant; `INVARIANT_VIOLATION` when the new person would break an enforced [access invariant](/docs/guides/governance/change-safety). The account starts active, with an unverified email and no roles. Without a password the person cannot sign in with one: send them a reset link with [`requestPasswordReset`](#requestpasswordreset), or use [`invite`](#invite) instead, which lets them choose it. `expiresAt` (epoch milliseconds) schedules deactivation. Declared attributes are set with [`update`](#update) or [`createMany`](#createmany). After the call commits, [automatic access-package rules](/docs/guides/privileged-access/access-packages#automatic-assignment) are reconciled for the new person, so a matching package applies right away. ```ts const contractor = await iam.api.identities.create(credential, { tenantId, email: 'sam@contractor.example', name: 'Sam Rivera', managerId: teamLeadId, expiresAt: Date.parse('2026-12-31T23:59:59Z'), }); ``` ```ts title="Signature" iam.api.identities.create( credential: CredentialInput, input: { tenantId: string; email: string; name: string; password?: string; expiresAt?: number; managerId?: string; }, ): Promise ``` ## createMany [#createmany] Creates up to 100 people in one transaction, each with optional attributes, roles, groups, password, and expiry. **HTTP:** `POST /api/iam/identities/createMany` (requires a credential) · **Browser client:** `client.identities.createMany()` * **Permission:** `iam:identities:create` on the tenant. With roles, also `iam:bindings:create` on each role and an active grant authority; with groups, `iam:groups:update` on each group and authority over each of its role bindings. * **Audited as:** `iam:identities:create`. * **Errors:** `INVALID_INPUT` for an empty list, more than 100 entries, or an undeclared or mistyped attribute; `ACCESS_DENIED` without the right to grant one of the roles or fill one of the groups; `PROTECTED_RESOURCE` for the Owner role; `GRANT_AUTHORITY_REQUIRED` when roles are given and you hold no active grant authority; `IDENTITY_EXISTS`, `LIMIT_EXCEEDED`, `SOD_CONFLICT`, and `INVARIANT_VIOLATION` as for single creation. Any failure rejects the whole batch. Use it for migrations and cohort onboarding. Roles are bound directly to each person under your grant authority and group memberships are permanent. An entry's `expiresAt` is that identity's deactivation date, not an expiry for its grants. Attributes are checked against `permissions.identityAttributes`. The rights to grant each role and fill each group are checked once for the whole batch, so an import can never grant more than you could bind by hand. ```ts const { identities } = await iam.api.identities.createMany(credential, { tenantId, identities: newHires.map((hire) => ({ email: hire.email, name: hire.name, attributes: { department: hire.department }, // declared in permissions.identityAttributes groupIds: [everyoneGroupId], })), }); ``` ```ts title="Signature" iam.api.identities.createMany( credential: CredentialInput, input: { tenantId: string; identities: Array<{ email: string; name: string; password?: string; attributes?: Record; roleIds?: string[]; groupIds?: string[]; expiresAt?: number; }>; }, ): Promise<{ identities: PublicIdentity[] }> ``` ## delete [#delete] Removes a person or service account for good, leaving a tombstone so audit records still name who acted. **HTTP:** `POST /api/iam/identities/delete` (requires a credential) · **Browser client:** `client.identities.delete()` * **Permission:** `iam:identities:delete` on the identity, with recent authentication. * **Audited as:** `iam:identities:delete` and `identity:delete` (with the kind and former email). * **Errors:** `CONFLICT` when the identity is already deleted; `INVALID_INPUT` when you try to delete yourself; `ACCESS_DENIED` for a root administrator unless you are root; `LAST_OWNER` or `LAST_ROOT_ADMIN` for the last active owner or root administrator; `RECENT_AUTH_REQUIRED` when your sign-in is not recent. In one transaction it ends every session, API key, remembered device, and pending challenge; deletes role bindings, group memberships, activations, package assignments and requests, relationships, boundaries, passkeys, MFA enrollment, password history, and external-provider mappings; revokes the grant authorities the identity held and its account links; cancels its pending access requests; and clears it as the manager of anyone who reported to it. Revoking its grant authorities means grants it issued as a delegated administrator stop applying (see [grant authorities](/docs/guides/authorization/roles#grant-authorities)). The tombstone keeps the ID, name, and kind with `status: 'deleted'`. It has no sign-in email, phone, or password; the former address is kept as `deletedEmail`. `list` leaves tombstones out unless asked, and `get` still returns them. Unlike `offboard`, deletion hands nothing to a successor, so for leavers call [`offboard`](#offboard) first and `delete` after your retention period. ```ts title="Signature" iam.api.identities.delete( credential: CredentialInput, input: { tenantId: string; identityId: string }, ): Promise ``` ## export [#export] Returns everything the tenant stores about one identity as JSON, to answer a data-subject access request. **HTTP:** `POST /api/iam/identities/export` (requires a credential) · **Browser client:** `client.identities.export()` * **Permission:** `iam:identities:read` on the identity, with recent authentication. Audit events are included only when you also hold `iam:audit:read` on the tenant. * **Audited as:** `iam:identities:read` and `identity:export` (with the kind and whether audit events were included). * **Errors:** `RECENT_AUTH_REQUIRED` when your sign-in is not recent; `NOT_FOUND` when the identity is not in this tenant. The export contains the public identity; its stored sessions and API keys without token hashes; whether MFA is enabled; passkey identifiers and transports; external-provider subjects; effective role bindings; groups; relationships; access requests; boundaries; grant authorities; account links; and SCIM links. With `iam:audit:read` it adds the audit events the identity performed, newest first and at most 5,000, and `auditIncluded` tells you which you got. Secrets, password hashes, and tokens are never included. Deleted identities can be exported too, which helps when a request arrives after the account was removed. ```ts title="Signature" iam.api.identities.export( credential: CredentialInput, input: { tenantId: string; identityId: string }, ): Promise<{ exportedAt: number; tenantId: string; identity: PublicIdentity; sessions: { [key: string]: unknown; identityId: string; createdAt: number; expiresAt: number; lastSeenAt: number; authenticatedAt: number; mfa: boolean; kind: 'user' | 'role' | 'api-key' | 'session-token' | 'delegated'; agentId?: string; delegationId?: string; mfaAuthenticatedAt?: number; credentialAuthorityId?: string; sessionName?: string; sourceIdentity?: string; sessionTags?: Record; sourcePolicy?: PolicyDocument; format?: 'jwt'; audience?: string[]; webIdentity?: { providerId: string; issuer: string; subject: string }; method?: AuthMethod; client?: SessionClientInfo; name?: string; description?: string; impersonatorId?: string; impersonatorSessionId?: string; trustedDeviceId?: string; originalIdentityId?: string; sourceTenantId?: string; roleId?: string; trustId?: string; sourceSessionId?: string; sourceAuthorityIds?: string[]; policy?: PolicyDocument; previousSignIn?: SignInRecord; id: string; tenantId: string; }[]; mfa: { enabled: boolean }; passkeys: { id: string; credentialId: unknown; transports: unknown }[]; externalIdentities: { providerId: unknown; issuer: unknown; subject: unknown }[]; bindings: EffectiveBinding[]; groups: Group[]; relationships: Relationship[]; accessRequests: AccessRequest[]; boundaries: PolicyDocument[]; grantAuthorities: { id: string; revoked: boolean; parentAuthorityId: string | undefined; }[]; links: { id: string; linkedIdentityId: string; revoked: boolean }[]; scim: { connectionId: unknown; externalId: unknown; userName: unknown; active: unknown; }[]; auditIncluded: boolean; audit: AuditEvent[] | undefined; }> ``` ## get [#get] Returns one identity by ID, including the tombstone of a deleted identity. **HTTP:** `POST /api/iam/identities/get` (requires a credential) · **Browser client:** `client.identities.get()` * **Permission:** `iam:identities:read` on the identity. * **Audited as:** `iam:identities:read`. * **Errors:** `NOT_FOUND` when the identity is not in this tenant. Password hashes are never returned. ```ts title="Signature" iam.api.identities.get( credential: CredentialInput, input: { tenantId: string; identityId: string }, ): Promise ``` ## impersonate [#impersonate] Opens a short-lived "view as" session as a member, for support and troubleshooting, and returns its token. **HTTP:** `POST /api/iam/identities/impersonate` (requires a credential) · **Browser client:** `client.identities.impersonate()` * **Permission:** `iam:identities:impersonate` on the member, with recent authentication, and the tenant's [authentication policy](/docs/guides/authentication/tenant-policy) must set `allowImpersonation`. * **Audited as:** `iam:identities:impersonate` and `identity:impersonate` (with the reason, the new session ID, and its expiry). * **Errors:** `FEATURE_DISABLED` when the tenant does not allow impersonation; `ACCESS_DENIED` for an owner or root administrator; `INVALID_INPUT` for yourself, a service account, a missing reason, or a `durationMs` outside one minute to eight hours; `IMPERSONATION_RESTRICTED` unless you act through an ordinary session of your own; `MFA_REQUIRED` when the member requires MFA and your session did not complete it; `IP_NOT_ALLOWED` or `IP_BLOCKED` when the tenant's network rules refuse your address; `RECENT_AUTH_REQUIRED`. The session lasts `durationMs` (one hour by default) and never outlives your own session. Each operation it attempts is allowed only when both the member and you may perform it, and it cannot do anything that needs recent authentication, assume roles, or grant OAuth consent. Every audit record it produces carries `impersonatorId`, policies see `principal.impersonated`, and the member sees the session in their own session list. Over HTTP the token is returned in the body only, never as a cookie, so keep it in a separate context such as a dedicated tab. See [impersonation](/docs/guides/authentication/impersonation). ```ts const { token, session } = await iam.api.identities.impersonate(credential, { tenantId, identityId: memberId, reason: 'Ticket 4821: export button missing', durationMs: 30 * 60_000, }); ``` ```ts title="Signature" iam.api.identities.impersonate( credential: CredentialInput, input: { tenantId: string; identityId: string; reason: string; durationMs?: number }, ): Promise<{ token: string; session: { [x: string]: unknown; identityId: string; createdAt: number; expiresAt: number; lastSeenAt: number; authenticatedAt: number; mfa: boolean; kind: 'user' | 'role' | 'api-key' | 'session-token' | 'delegated'; agentId?: string | undefined; delegationId?: string | undefined; mfaAuthenticatedAt?: number | undefined; credentialAuthorityId?: string | undefined; sessionName?: string | undefined; sourceIdentity?: string | undefined; sessionTags?: Record | undefined; sourcePolicy?: PolicyDocument | undefined; format?: 'jwt' | undefined; audience?: string[] | undefined; webIdentity?: { providerId: string; issuer: string; subject: string } | undefined; method?: AuthMethod | undefined; client?: SessionClientInfo | undefined; name?: string | undefined; description?: string | undefined; impersonatorId?: string | undefined; impersonatorSessionId?: string | undefined; trustedDeviceId?: string | undefined; originalIdentityId?: string | undefined; sourceTenantId?: string | undefined; roleId?: string | undefined; trustId?: string | undefined; sourceSessionId?: string | undefined; sourceAuthorityIds?: string[] | undefined; policy?: PolicyDocument | undefined; previousSignIn?: SignInRecord | undefined; id: string; tenantId: string; }; identity: PublicIdentity; }> ``` ## invite [#invite] Invites a person to the tenant by email, with roles and groups they receive when they accept. **HTTP:** `POST /api/iam/identities/invite` (requires a credential) · **Browser client:** `client.identities.invite()` * **Permission:** `iam:identities:create` on the tenant. With roles, also `iam:bindings:create` on each role and an active grant authority; with groups, `iam:groups:update` on each group and authority over each of its role bindings. * **Audited as:** `iam:identities:create`. * **Errors:** `DELIVERY_REQUIRED` without an email delivery callback; `IDENTITY_EXISTS` when the email already belongs to an identity in this tenant; `PROTECTED_RESOURCE` for the Owner role; `ACCESS_DENIED` without the right to grant one of the roles or fill one of the groups; `GRANT_AUTHORITY_REQUIRED` when roles or groups are given and you hold no active grant authority; `NOT_FOUND` for an unknown role or group. The result has the invitation ID and expiry but never the token, which travels only in the email. `name` is a suggestion the person can change when accepting. Nothing is granted until acceptance; see [Invitations](#invitations). Inviting the same address again does not cancel an earlier invitation, so use [`resendInvitation`](#resendinvitation) for a lost email. If the person already has a disabled account, re-enable it with [`setStatus`](#setstatus) instead. ```ts const invitation = await iam.api.identities.invite(credential, { tenantId, email: 'alice@example.com', name: 'Alice Chen', roleIds: [editorRoleId], groupIds: [designGroupId], }); // invitation.expiresAt: when the link stops working ``` ```ts title="Signature" iam.api.identities.invite( credential: CredentialInput, input: { tenantId: string; email: string; name?: string; roleIds?: string[]; groupIds?: string[]; }, ): Promise<{ invitationId: string; email: string; expiresAt: number; roleIds: string[]; groupIds: string[]; }> ``` ## list [#list] Lists the tenant's people and service accounts, with filters and paging. **HTTP:** `POST /api/iam/identities/list` (requires a credential) · **Browser client:** `client.identities.list()` * **Permission:** `iam:identities:read` on the tenant. * **Audited as:** `iam:identities:read`. * **Errors:** `INVALID_INPUT` for an unknown `kind` or `status`, or a `limit` outside 1 to 1,000. Results are ordered by name, then ID. `kind` (`user` or `service`) and `status` (`active`, `disabled`, `deleted`) narrow the list; tombstones are left out unless you pass `includeDeleted` or ask for `status: 'deleted'`. `query` matches the name or email case-insensitively. `expiresBefore` keeps identities whose scheduled deactivation is at or before that time, including ones already past it. `limit` and `offset` page through the result. ```ts // Active accounts that end within the next 14 days. const ending = await iam.api.identities.list(credential, { tenantId, status: 'active', expiresBefore: Date.now() + 14 * 86_400_000, }); ``` ```ts title="Signature" iam.api.identities.list( credential: CredentialInput, input: { tenantId: string; kind?: Identity['kind']; status?: Identity['status']; includeDeleted?: boolean; query?: string; expiresBefore?: number; limit?: number; offset?: number; }, ): Promise ``` ## listBindings [#listbindings] Lists an identity's role bindings, direct and through its groups, with their activation and window state. **HTTP:** `POST /api/iam/identities/listBindings` (requires a credential) · **Browser client:** `client.identities.listBindings()` * **Permission:** `iam:bindings:read` on the identity. * **Audited as:** `iam:bindings:read`. * **Errors:** `NOT_FOUND` when the identity is not in this tenant. Each entry is the binding with its `role` and `via` (`'identity'`, or `{ groupId }` for a group binding). [Eligible bindings](/docs/guides/privileged-access/elevation) carry `activation` while activated and `pendingActivation` while a request awaits approval, and bindings with an access window carry `inWindow`. Future-dated bindings are listed with their start; expired bindings and lapsed memberships are left out. The list shows what is bound, not a decision: to see why a specific action is allowed, use [access paths](/docs/guides/governance/access-paths). ```ts title="Signature" iam.api.identities.listBindings( credential: CredentialInput, input: { tenantId: string; identityId: string }, ): Promise ``` ## listGroups [#listgroups] Lists the groups an identity currently belongs to, with `membershipExpiresAt` on temporary memberships. **HTTP:** `POST /api/iam/identities/listGroups` (requires a credential) · **Browser client:** `client.identities.listGroups()` * **Permission:** `iam:groups:read` on the identity. * **Audited as:** `iam:groups:read`. * **Errors:** `NOT_FOUND` when the identity is not in this tenant. Lapsed memberships are left out even before the purge job removes them, so the list matches the groups that currently give the identity roles. ```ts title="Signature" iam.api.identities.listGroups( credential: CredentialInput, input: { tenantId: string; identityId: string }, ): Promise<(Group & { membershipExpiresAt?: number })[]> ``` ## listInvitations [#listinvitations] Lists every member invitation of the tenant, pending or not, without tokens. **HTTP:** `POST /api/iam/identities/listInvitations` (requires a credential) · **Browser client:** `client.identities.listInvitations()` * **Permission:** `iam:identities:read` on the tenant. * **Audited as:** `iam:identities:read`. Each invitation shows the email, suggested name, roles, groups, the inviter, the grant authority its roles will be issued under (present only when it carries roles or groups), when it was created and expires, and whether it was `consumed` or `revoked`. An invitation past `expiresAt` that is neither has simply expired; [`resendInvitation`](#resendinvitation) renews it. ```ts title="Signature" iam.api.identities.listInvitations( credential: CredentialInput, input: { tenantId: string }, ): Promise<{ [key: string]: unknown; email: string; name?: string; roleIds: string[]; groupIds: string[]; authorityId?: string; inviterId: string; createdAt: number; expiresAt: number; consumed: boolean; revoked?: boolean; id: string; tenantId: string; }[]> ``` ## listReports [#listreports] Lists the active people whose manager is this identity, by name. **HTTP:** `POST /api/iam/identities/listReports` (requires a credential) · **Browser client:** `client.identities.listReports()` * **Permission:** `iam:identities:read` on the identity. * **Audited as:** `iam:identities:read`. * **Errors:** `NOT_FOUND` when the identity is not in this tenant. Disabled and deleted reports are left out. Managers are set with `managerId` on [`create`](#create) or [`update`](#update), and they can approve requests for eligible bindings and access packages that ask for manager approval (see [approver groups and managers](/docs/guides/privileged-access/elevation#approver-groups-and-managers)). ```ts title="Signature" iam.api.identities.listReports( credential: CredentialInput, input: { tenantId: string; identityId: string }, ): Promise ``` ## listSessions [#listsessions] Lists an identity's unexpired sessions and API keys, most recently used first, without token hashes. **HTTP:** `POST /api/iam/identities/listSessions` (requires a credential) · **Browser client:** `client.identities.listSessions()` * **Permission:** `iam:identities:read` on the identity. * **Audited as:** `iam:identities:read`. * **Errors:** `NOT_FOUND` when the identity is not in this tenant. Use it for device lists and support. Each session shows when it was created, last used, and expires, how it was established (`method`), whether it completed MFA, the client details recorded at sign-in, and `impersonatorId` for "view as" sessions. End them with [`revokeSessions`](#revokesessions). ```ts title="Signature" iam.api.identities.listSessions( credential: CredentialInput, input: { tenantId: string; identityId: string }, ): Promise<{ [key: string]: unknown; identityId: string; createdAt: number; expiresAt: number; lastSeenAt: number; authenticatedAt: number; mfa: boolean; kind: 'user' | 'role' | 'api-key' | 'session-token' | 'delegated'; agentId?: string; delegationId?: string; mfaAuthenticatedAt?: number; credentialAuthorityId?: string; sessionName?: string; sourceIdentity?: string; sessionTags?: Record; sourcePolicy?: PolicyDocument; format?: 'jwt'; audience?: string[]; webIdentity?: { providerId: string; issuer: string; subject: string }; method?: AuthMethod; client?: SessionClientInfo; name?: string; description?: string; impersonatorId?: string; impersonatorSessionId?: string; trustedDeviceId?: string; originalIdentityId?: string; sourceTenantId?: string; roleId?: string; trustId?: string; sourceSessionId?: string; sourceAuthorityIds?: string[]; policy?: PolicyDocument; previousSignIn?: SignInRecord; id: string; tenantId: string; }[]> ``` ## offboard [#offboard] Disables an identity and removes everything that gave it access in one transaction, handing what it owned to a successor. **HTTP:** `POST /api/iam/identities/offboard` (requires a credential) · **Browser client:** `client.identities.offboard()` * **Permission:** `iam:identities:update` on the identity, with recent authentication. Offboarding an owner also requires you to be an owner of this tenant, signed in to it with your own account, or root. Direct bindings and group memberships are removed as `bindings.delete` and `groups.removeMember` would, under your grant authority. * **Audited as:** `iam:identities:update` and `identity:offboard` (with the reason, kind, successor, and every count). * **Errors:** `INVALID_INPUT` for yourself, a missing reason, or a successor who is the same identity or not active; `ACCESS_DENIED` for a root administrator unless you are root, for an owner unless you are an owner or root, or for a binding or group membership issued under another administrator's grant authority; `LAST_OWNER` or `LAST_ROOT_ADMIN`; `NOT_FOUND` for an unknown or deleted identity or successor; `RECENT_AUTH_REQUIRED`. In order, it removes ownership (the protected Owner binding); ends role activations; revokes [access-package](/docs/guides/privileged-access/access-packages) assignments with the bindings and memberships they created; deletes the remaining direct bindings and group memberships; deletes relationships; cancels pending access and package requests; revokes the grant authorities the identity holds, so grants it issued as a delegated administrator stop applying; moves its reports to the successor; transfers the managed resources it owns to the successor; ends every session and API key; and disables it. Without a successor, reports are left without a manager and owned resources are only counted (`resourcesOwned`). The result counts each step, which makes a good record for auditors. The identity stays as a disabled record so the audit trail still names who they were; remove it later with [`delete`](#delete). Package rules owned by the leaver are suspended when their authority is revoked, so hand them over first. See [offboarding](/docs/guides/privileged-access/lifecycle#offboarding). ```ts const summary = await iam.api.identities.offboard(credential, { tenantId, identityId: leaverId, reason: 'Left the company (HR-1234)', successorId: managerId, }); // summary.bindings, summary.memberships, summary.resourcesReassigned, ... ``` ```ts title="Signature" iam.api.identities.offboard( credential: CredentialInput, input: { tenantId: string; identityId: string; reason: string; successorId?: string }, ): Promise<{ departmentsReassigned?: number | undefined; teamsLeft?: number | undefined; delegationsRevoked?: number | undefined; agentsUnsponsored?: number | undefined; agentsReassigned?: number | undefined; sessions: number; bindings: number; memberships: number; activations: number; packages: number; relationships: number; accessRequests: number; authorities: number; resourcesReassigned: number; resourcesOwned: number; reportsReassigned: number; identity: PublicIdentity; }> ``` ## requestPasswordReset [#requestpasswordreset] Emails a member a password-reset link on an administrator's behalf. **HTTP:** `POST /api/iam/identities/requestPasswordReset` (requires a credential) · **Browser client:** `client.identities.requestPasswordReset()` * **Permission:** `iam:identities:update` on the member, with recent authentication. For an owner you must be another owner of the same tenant or root; for a root administrator, root. * **Audited as:** `iam:identities:update` and `identity:password-reset`. * **Errors:** `FEATURE_DISABLED` when email delivery or password sign-in is not configured; `INVALID_INPUT` for a service account, a disabled identity, or one without an email; `ACCESS_DENIED` for an owner or root administrator you may not control; `RECENT_AUTH_REQUIRED`. Unlike the public `auth.requestPasswordReset`, it works whether or not the address is verified, which makes it the way to onboard someone created without a password. It returns `{ queued: true, email }`; the reset token goes only to the member's inbox. Whoever controls a password reset controls the account, so the owner and root rules keep `iam:identities:update` alone from taking over a more powerful account. See [recovery](/docs/guides/authentication/recovery#resetting-on-someones-behalf). ```ts title="Signature" iam.api.identities.requestPasswordReset( credential: CredentialInput, input: { tenantId: string; identityId: string }, ): Promise<{ queued: boolean; email: string | undefined }> ``` ## resendInvitation [#resendinvitation] Sends a member invitation again with a new token and a fresh lifetime; the earlier link stops working. **HTTP:** `POST /api/iam/identities/resendInvitation` (requires a credential) · **Browser client:** `client.identities.resendInvitation()` * **Permission:** `iam:identities:update` on the invitation. * **Audited as:** `iam:identities:update`. * **Errors:** `CONFLICT` when the invitation was already accepted or revoked; `DELIVERY_REQUIRED` without an email delivery callback; `NOT_FOUND` when the invitation is not in this tenant. Use it when the first email expired, was lost, or went to spam: expired invitations can be resent. The new email names you as the inviter, while the invitation keeps its original inviter, roles, groups, and grant authority. ```ts title="Signature" iam.api.identities.resendInvitation( credential: CredentialInput, input: { tenantId: string; invitationId: string }, ): Promise<{ invitationId: string; email: string; expiresAt: number }> ``` ## revokeInvitation [#revokeinvitation] Cancels a member invitation so its link can no longer be used. **HTTP:** `POST /api/iam/identities/revokeInvitation` (requires a credential) · **Browser client:** `client.identities.revokeInvitation()` * **Permission:** `iam:identities:update` on the invitation. * **Audited as:** `iam:identities:update`. * **Errors:** `CONFLICT` when the invitation was already accepted or revoked; `NOT_FOUND` when it is not in this tenant. The invitation stays in [`listInvitations`](#listinvitations) with `revoked: true`, and a revoked invitation cannot be re-sent. ```ts title="Signature" iam.api.identities.revokeInvitation( credential: CredentialInput, input: { tenantId: string; invitationId: string }, ): Promise<{ revoked: boolean; email: string; name?: string; roleIds: string[]; groupIds: string[]; authorityId?: string; inviterId: string; createdAt: number; expiresAt: number; consumed: boolean; id: string; tenantId: string; }> ``` ## revokeSessions [#revokesessions] Ends every session and API key of an identity without disabling it. **HTTP:** `POST /api/iam/identities/revokeSessions` (requires a credential) · **Browser client:** `client.identities.revokeSessions()` * **Permission:** `iam:identities:update` on the identity, with recent authentication. * **Audited as:** `iam:identities:update` and `identity:revoke-sessions` (with the number revoked). * **Errors:** `ACCESS_DENIED` for a root administrator unless you are root; `NOT_FOUND` when the identity is not in this tenant; `RECENT_AUTH_REQUIRED`. Use it for incident response or a lost device. Remembered devices and pending sign-in challenges are cleared too, so the next sign-in needs the second factor again, and role sessions assumed from the identity and "view as" sessions opened through its sessions end as well. The account and its access stay, so a person can sign in again at once. For a service account this deletes its API keys; issue new ones with [`credentials.create`](/docs/reference/api/credentials#create). The result's `revoked` is the number of session records the identity held. Pass `keepApiKeys: true` to end everything except the API keys: user sessions, role sessions the identity assumed in other tenants, session tokens (including those minted from its keys), remembered devices, and pending challenges end, while the keys keep working. Use it when a service account's session tokens may have leaked but its keys have not. The audit event records `keptApiKeys`. A value other than `true` or `false` is `INVALID_INPUT`. ```ts title="Signature" iam.api.identities.revokeSessions( credential: CredentialInput, input: { tenantId: string; identityId: string; keepApiKeys?: boolean }, ): Promise<{ revoked: number }> ``` ## setBoundary [#setboundary] Sets a root-controlled permissions boundary on one identity, capping what it may do in the tenant whatever its roles grant. **HTTP:** `POST /api/iam/identities/setBoundary` (requires a credential) · **Browser client:** `client.identities.setBoundary()` * **Permission:** `iam:boundaries:update` on the identity, and you must be a root administrator. * **Audited as:** `iam:boundaries:update`. * **Errors:** `ACCESS_DENIED` for anyone but root; `INVALID_POLICY`, `INVALID_ACTION`, or `INVALID_RESOURCE_TYPE` for a document the catalog rejects; `NOT_FOUND` when the identity is not in this tenant; `INVARIANT_VIOLATION` when the change would break an enforced access invariant. A boundary never grants: an action is allowed only when a role grants it and the boundary allows it too, and boundaries set on the tenant and its ancestors apply on top. Each identity has at most one boundary per tenant, and calling again replaces it. Boundaries are platform controls, which is why tenant administrators cannot set them. See [boundaries](/docs/guides/authorization/policies#boundaries). ```ts // A vendor account may never reach beyond support tickets, whatever roles it is given. await iam.api.identities.setBoundary(rootCredential, { tenantId, identityId: vendorId, document: { version: 1, statements: [{ effect: 'allow', actions: ['tickets:*'], resources: ['*'] }], }, }); ``` ```ts title="Signature" iam.api.identities.setBoundary( credential: CredentialInput, input: { tenantId: string; identityId: string; document: PolicyDocument }, ): Promise<{ id: string; tenantId: string; uniqueKey: string; identityId: string; document: PolicyDocument; }> ``` ## setOwner [#setowner] Makes a member an owner of the tenant, or removes their ownership. **HTTP:** `POST /api/iam/identities/setOwner` (requires a credential) · **Browser client:** `client.identities.setOwner()` * **Permission:** `iam:identities:update` on the member, with recent authentication, and you must be an owner of this tenant yourself (signed in to it with your own account, not through an assumed role) or root. * **Audited as:** `iam:identities:update`. * **Errors:** `ACCESS_DENIED` when you are not an owner or root; `INVALID_INPUT` for a service account, a disabled member, or a non-boolean `owner`; `LAST_OWNER` when removing the last active owner; `RECENT_AUTH_REQUIRED`. Ownership is the protected [Owner role](/docs/guides/authorization/roles#the-owner-role), which allows every action in the tenant and cannot be bound, edited, or requested any other way. Granting it binds the Owner role under a new, unrestricted grant authority delegated from the one you grant under, so the new owner can administer and delegate like you, within your authority chain; if an authority above theirs is revoked (offboarding or deleting you revokes yours), their grants stop applying. Removing ownership deletes the Owner binding but leaves the person's grant authorities, so grants they issued keep applying; revoke those with [`authorities.revoke`](/docs/reference/api/authorities#revoke) if they should not. ```ts title="Signature" iam.api.identities.setOwner( credential: CredentialInput, input: { tenantId: string; identityId: string; owner: boolean }, ): Promise ``` ## setStatus [#setstatus] Disables an identity or re-enables it. **HTTP:** `POST /api/iam/identities/setStatus` (requires a credential) · **Browser client:** `client.identities.setStatus()` * **Permission:** `iam:identities:update` on the identity, with recent authentication. * **Audited as:** `iam:identities:update`. * **Errors:** `INVALID_INPUT` for a status other than `active` or `disabled`; `ACCESS_DENIED` for a root administrator unless you are root; `LAST_OWNER` or `LAST_ROOT_ADMIN` when disabling the last active owner or root administrator; `INVALID_TRANSITION` (409) when re-enabling an identity whose `expiresAt` has passed; `NOT_FOUND` for a deleted identity; `INVARIANT_VIOLATION` when the change would break an enforced access invariant. Disabling ends every session and API key at once, and the identity can no longer sign in or authenticate. Its roles, groups, and attributes are kept and apply again when you re-enable it, but ended sessions and keys do not come back. It works for people and service accounts alike ([`serviceAccounts.setStatus`](/docs/reference/api/service-accounts#setstatus) is the service-account equivalent). Access-package rules are reconciled after the change and never assign anything to a disabled identity. To remove access for good, use [`offboard`](#offboard). ```ts title="Signature" iam.api.identities.setStatus( credential: CredentialInput, input: { tenantId: string; identityId: string; status: 'active' | 'disabled' }, ): Promise ``` ## unlock [#unlock] Clears the rate-limit counters that lock a person out of sign-in, recovery, and MFA after too many attempts. **HTTP:** `POST /api/iam/identities/unlock` (requires a credential) · **Browser client:** `client.identities.unlock()` * **Permission:** `iam:identities:update` on the identity, with recent authentication. * **Audited as:** `iam:identities:update` and `identity:unlock` (with `supported` and `cleared`). * **Errors:** `RECENT_AUTH_REQUIRED`; `NOT_FOUND` for an unknown or deleted identity. It resets the counters kept for the identity's email, phone, and ID across sign-in, sign-up, re-authentication, email verification and change, phone verification, password reset and change, passwordless, passkey, and second-factor flows. Counters kept per client address and [network blocks](/docs/reference/api/security#unblocknetwork) are not affected. A custom limiter without a `reset` method returns `{ supported: false, cleared: 0 }`; otherwise `cleared` is the number of counters reset. See [lockouts](/docs/guides/authentication/recovery#lockouts). ```ts title="Signature" iam.api.identities.unlock( credential: CredentialInput, input: { tenantId: string; identityId: string }, ): Promise<{ supported: boolean; cleared: number }> ``` ## update [#update] Changes an identity's name, declared attributes, email, manager, or scheduled deactivation. **HTTP:** `POST /api/iam/identities/update` (requires a credential) · **Browser client:** `client.identities.update()` * **Permission:** `iam:identities:update` on the identity. An email change also needs recent authentication, and for an owner or root administrator the same control as [`requestPasswordReset`](#requestpasswordreset); changing a root administrator's expiry needs root. * **Audited as:** `iam:identities:update`, plus `identity:email-change` (with the old and new address) when the email changes. * **Errors:** `INVALID_INPUT` when no field is given, for an undeclared or mistyped attribute, an `expiresAt` that is not in the future or is more than ten years ahead, an email on a service account, or a manager who is the identity itself, is not active, or reports to the identity (directly or further down); `IDENTITY_EXISTS` when the new email is taken in this tenant; `LAST_OWNER` when setting an expiry on the last active owner; `ACCESS_DENIED` for a protected account you may not change; `NOT_FOUND` for a deleted identity; `INVARIANT_VIOLATION` when the change would break an enforced access invariant. Only the fields you pass change. `attributes` replaces the whole set of declared attributes, validated against `permissions.identityAttributes`. A new email is marked unverified and every session ends, because whoever controls the sign-in address controls the account. `expiresAt: null` clears a scheduled deactivation and `managerId: null` removes the manager; to end access immediately, disable the identity instead of setting an expiry. After the change, automatic access-package rules are reconciled for the identity, so new attribute values can change which packages it receives. ```ts await iam.api.identities.update(credential, { tenantId, identityId, attributes: { department: 'finance', level: 3 }, managerId: newManagerId, expiresAt: null, // no longer a temporary account }); ``` ```ts title="Signature" iam.api.identities.update( credential: CredentialInput, input: { tenantId: string; identityId: string; name?: string; attributes?: Record; email?: string; expiresAt?: number | null; managerId?: string | null; }, ): Promise ``` # impact (/docs/reference/api/impact) > Impact previews show who would gain or lose which actions before you edit a role or policy or delete a role. Impact previews show who would gain or lose which actions before you edit a role or policy or delete a role. A role edit reaches everyone who holds the role, directly, through groups, and through every role that inherits it, and the effect depends on conditions, ceilings, and boundaries that are hard to reason about by reading documents. A preview answers "what happens if I make this change?" with the real evaluator, and tells you which [access invariants](/docs/reference/api/invariants) the change would break or fix, without saving anything. The guide is [change safety](/docs/guides/governance/change-safety). ## How a preview works [#how-a-preview-works] 1. The server finds the affected roles: the changed role, or every role that attaches the changed policy, plus every role that inherits them, transitively. 2. It collects the holders: active identities (people and service accounts) bound to those roles, directly or through a live group membership, up to 200. 3. It evaluates each holder against each of your 1 to 10 `resources`, for every known action or the `actions` you list, then applies the change exactly as the real call would (same validation, same permission, same edit rights) and evaluates again. 4. It compares the invariants before and after, and rolls the transaction back. Because the ordinary evaluator runs, conditions, authority ceilings, boundaries, access windows, and just-in-time eligibility all count. An eligible holder without a live activation holds nothing before or after the change, so they show no difference. The change is simulated, not made, so enforced invariants are reported here rather than refused. | Method | What it does | Access | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | | [`preview`](#preview) | Simulates a role update, a policy document change, or a role deletion, and reports the actions each holder would gain and lose per resource, plus the access invariants the change would break or fix. | Credential | ## preview [#preview] Simulates a role update, a policy document change, or a role deletion, and reports the actions each holder would gain and lose per resource, plus the access invariants the change would break or fix. **HTTP:** `POST /api/iam/impact/preview` (requires a credential) · **Browser client:** `client.impact.preview()` * **Permission:** `iam:policies:simulate` on the tenant, plus what the real change needs: `iam:roles:update` or `iam:roles:delete` on the role, or `iam:policies:update` on the policy, and the edit rights of the grant authority that created it (or root). * **Audited as:** `iam:policies:simulate`. The simulated change is not audited, because it never happens. * **Errors:** `INVALID_INPUT` when `change` does not name exactly one of `role`, `policy`, or `deleteRole`, when `resources` does not hold 1 to 10 entries, or when `actions` does not hold 1 to 200; `INVALID_ACTION` for an action missing from the catalog; `ACCESS_DENIED` when you lack the permission or edit rights the change needs; `IMPERSONATION_RESTRICTED` from a "view as" session; `NOT_FOUND` when the role, policy, or a managed resource does not exist; and any error the real call would raise, such as `RESOURCE_IN_USE` for deleting a role that others inherit, `PROTECTED_RESOURCE`, or `INVALID_POLICY`. `change` takes one of three shapes: `{ role: { roleId, ...update } }` with the fields [`roles.update`](/docs/reference/api/roles#update) accepts, `{ policy: { policyId, document } }` for a new policy document, or `{ deleteRole: roleId }`. `assumeMfa: true` evaluates holders as MFA-verified. The result lists the affected `roles`, the number of holders `evaluated` (with `truncated: true` when more than 200 were skipped), and `identities`: only the holders whose access changes, each with `changes` per resource (`gained` and `lost` action names). `gainedTotal` and `lostTotal` sum them up, and `invariants` lists those the change would newly break (`broken`, with the new violations) or make pass again (`fixed`). ```ts const preview = await iam.api.impact.preview(credential, { tenantId, change: { role: { roleId: approver.id, permissions: ['payments:read', 'payments:approve'] } }, resources: [{ type: 'ledger', id: 'main' }], }); // preview.identities: [{ identity: { id, name }, changes: [{ resource: 'ledger/main', gained: [...], lost: [...] }] }] // preview.invariants.broken: guardrails the change would break ``` ```ts title="Signature" iam.api.impact.preview( credential: CredentialInput, input: { tenantId: string; change: ImpactChange; resources: { type: string; id: string }[]; actions?: string[]; assumeMfa?: boolean; }, ): Promise ``` # API reference (/docs/reference/api) > Every method of the Better IAM server API: 45 groups and 417 methods, what each does, its HTTP route, and its TypeScript signature. `betterIam(options)` returns one object that is your whole identity server. You create it once, usually in `lib/iam.ts`, export it as `iam`, and import it wherever server code needs to check access, serve the IAM routes, or run maintenance. This reference covers all of it: the `api` groups that manage organizations, people, and access (listed under Groups), and the functions on the instance itself (listed under Instance functions). | Member | What it is for | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `api` | Every provisioning and authentication method, by group: `iam.api.groups.addMember(credential, input)`. The HTTP routes and the browser client call the same methods. | | `authorize`, `authorizeMany`, `listAccessible`, `require`, `authenticate` | The checks your own routes, pages, and jobs run before they touch product data. | | `callPlugin` | Calls a plugin endpoint through the same authorized, audited envelope as built-in methods. | | `handler`, `nodeHandler` | The HTTP transports. Mount one of them at `basePath` (default `/api/iam`); see [HTTP handlers](#http-handlers). | | `events` | `subscribe(patterns, handler)` for in-process audit subscribers, and `dispatch()` to run them (the same function as `dispatchAuditHooks`). | | `useProtocol`, `protocolHost` | Mount OAuth, SAML, and SCIM services, and the host callbacks those packages are built from. | | `initialize`, `bootstrap`, `recoverRoot`, `rotateSecrets`, `selfCheck` | Deployment operations: schema, the first administrator, break-glass recovery, secret rotation, and health findings. | | `purgeDeleted`, `sweepExpired`, `reconcilePackages`, `sendAccessDigest`, `sendExpiryReminders`, `closeOverdueCertifications`, `checkInvariants`, `archiveAudit`, `pruneAudit`, `dispatchAuditHooks`, `flushAccessUsage` | Scheduled jobs and shutdown work. See [Scheduled jobs](/docs/operations/jobs). | | `assertionKey`, `assertionKeys` | The keys downstream services verify [assertions](/docs/reference/api/assertions#issue) with. | | `auth` | Low-level authentication primitives for trusted integrations, such as `dispatchOutbox()` and `withClient()`. | | `store` | The storage adapter you passed as `database`. | | `metrics`, `sessionTokens`, `endpoint` | Prometheus-style metrics (with `observability.metrics`), session JWT keys and online verification (with `sts.jwt`), and where the handler is mounted (origin, `basePath`, cookie settings) for framework integrations. | Keep the instance on the server. `auth`, `store`, `bootstrap`, `recoverRoot`, `assertionKey`, and `protocolHost` act with the deployment's authority rather than a caller's permissions. The HTTP router leaves them out on purpose, and you should never expose them through RPC reflection or send them to a browser. ## Three ways to call it [#three-ways-to-call-it] Every group method is called three ways: on the server as `iam.api.{group}.{method}(credential, input)`, from the typed browser client as `client.{group}.{method}(input)`, and over HTTP as `POST {basePath}/{group}/{method}` with a JSON body. The default `basePath` is `/api/iam`. The first server argument, the credential, says who is calling: a session token, an API key, or the incoming request headers (which carry the session cookie or an `Authorization: Bearer` token). Over HTTP every call is a `POST` with `Content-Type: application/json` and the `X-Better-IAM: 1` header (anything else is refused with `CSRF_REJECTED`). Requests that carry cookies must also send an exact trusted `Origin`. Responses use a `{ "data": … }` or `{ "error": { "code", "message" } }` envelope; the browser client unwraps it for you. Root bootstrap, recovery, raw storage, and session-issuance primitives are never exposed over HTTP. > **OpenAPI 3.1 specification.** The HTTP API is also described as an [OpenAPI 3.1 document](/openapi.json), generated from the same TypeScript types as this reference, with JSON Schemas for every request and response. Import it into Postman, Insomnia, or Bruno, or feed it to a client generator for languages other than TypeScript. **Server:** ```ts import { iam } from './iam'; // The first argument is the caller: a session token, an API key, or request headers. const credential = { headers: request.headers }; const group = await iam.api.groups.create(credential, { tenantId, name: 'Finance' }); ``` **Browser client:** ```ts import { createIamClient } from 'better-iam/client'; import type { iam } from './iam'; const client = createIamClient({ baseURL: 'https://identity.example.com' }); const group = await client.groups.create({ tenantId, name: 'Finance' }); // the session cookie is the credential ``` **HTTP:** ```bash curl -X POST https://identity.example.com/api/iam/groups/create \ -H "Authorization: Bearer $BETTER_IAM_TOKEN" \ -H "Content-Type: application/json" \ -H "X-Better-IAM: 1" \ -d '{ "tenantId": "ten_…", "name": "Finance" }' ``` ## HTTP handlers [#http-handlers] `iam.handler(request)` serves the Fetch API (a `Request` in, a `Response` out) and `iam.nodeHandler(req, res)` serves Node's `http` interface. They route identically, so mount exactly one of them. Both need Node.js 22.12 or later; edge runtimes are not supported. They answer: * `POST {basePath}/{group}/{method}` for every routed group method, plus `POST {basePath}/authorize`, `/authorizeMany`, `/listAccessible`, and `/plugins/{pluginId}/{path}` for plugin endpoints; * `GET {basePath}/health` (one database read, status 503 when storage fails), `GET {basePath}/metrics` (only with `observability.metrics.bearerToken`), and `GET {basePath}/.well-known/jwks.json` (only with `sts.jwt`); * the routes of every protocol service mounted with [`useProtocol`](#useprotocol), which are asked first. `nodeHandler` is required when you mount the OAuth authorization server, which only speaks Node's request and response objects. It offers each request to the Node protocol mounts first and hands everything else to `handler`. Both enforce the browser boundary: JSON bodies with `X-Better-IAM: 1`, an exact trusted `Origin` for requests that carry cookies (`CSRF_REJECTED` or `UNTRUSTED_ORIGIN` otherwise), and API bodies of at most 64 KiB (`PAYLOAD_TOO_LARGE`). They set the session cookie on sign-in routes and clear it on sign-out, add CORS headers for trusted origins, echo a plain `X-Request-Id`, and return every error as `{ "error": { "code", "message" } }`. The framework packages ([Next.js](/docs/frameworks/nextjs), [NestJS](/docs/frameworks/nestjs), [Node middleware](/docs/frameworks/node)) wrap them for you. ```ts import { createServer } from 'node:http'; import { iam } from './lib/iam'; // A dedicated identity server: IAM routes, health, metrics, and every mounted protocol. createServer((req, res) => iam.nodeHandler(req, res)).listen(3000); // Or, inside a Fetch-style router running on Node (Hono shown): app.all('/api/iam/*', (c) => iam.handler(c.req.raw)); ``` ## When to call each function [#when-to-call-each-function] | When | Functions | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | In the request path | `authenticate`, `authorize`, `require`, `authorizeMany`, `listAccessible`, `callPlugin` | | Once at process start | `useProtocol`, `iam.events.subscribe` | | Once per deploy or installation | `initialize` (CLI `migrate`), `bootstrap`, `selfCheck` (CLI `doctor`) | | On a schedule | `dispatchAuditHooks`, `purgeDeleted`, `sweepExpired`, `reconcilePackages`, `sendAccessDigest`, `sendExpiryReminders`, `closeOverdueCertifications`, `checkInvariants`, `archiveAudit`, `pruneAudit` | | Rarely, by an operator | `recoverRoot`, `rotateSecrets`, `assertionKey`, `assertionKeys` | | At shutdown | `flushAccessUsage` | Deployment operations and jobs take no credential. They act as `deployment-operator`, the actor you see on the audit events they record, so protect the process and configuration that runs them like a root credential. Every job is safe to run again and to overlap with itself; each function below says how it avoids doing work twice, and [`selfCheck`](#selfcheck) reports the jobs that have stopped running. Most jobs have a matching [CLI command](/docs/reference/cli) for cron. ## Groups [#groups] ### Sign-in and people [#sign-in-and-people] Authenticate people and machines, manage who exists in a tenant, and how they are organized. - [auth](/docs/reference/api/auth): End-user authentication: sign-in methods, MFA, passkeys, sessions, devices, and account recovery. (40 methods) - [identities](/docs/reference/api/identities): People in a tenant: invitations, profiles, attributes, sessions, offboarding, and data-subject export. (24 methods) - [teams](/docs/reference/api/teams): Nested teams with maintainers, join requests, and access through a team-managed group. (27 methods) - [departments](/docs/reference/api/departments): The org chart: departments with heads, one department per person, import from attributes, heads as managers. (14 methods) - [serviceAccounts](/docs/reference/api/service-accounts): Service accounts for machines, with scheduled deactivation. (6 methods) - [credentials](/docs/reference/api/credentials): API keys with scopes, labels, expiry, and last-use tracking. (6 methods) - [links](/docs/reference/api/links): Explicit account linking between identities, and switching between linked accounts. (4 methods) - [domains](/docs/reference/api/domains): Verified email domains for discovery and single sign-on routing. (5 methods) - [security](/docs/reference/api/security): Network blocks for addresses and ranges. (3 methods) ### Organizations [#organizations] The tenant tree, organization addresses and settings, and platform administration. - [tenants](/docs/reference/api/tenants): Tenant trees: organizations, settings, authentication and access policies, limits, and usage. (19 methods) - [hostnames](/docs/reference/api/hostnames): Custom hostnames organizations verify with DNS and use as their own sign-in address. (5 methods) - [features](/docs/reference/api/features): Feature flags at platform and organization level: definitions, targets, overrides, and evaluation. (8 methods) - [onboarding](/docs/reference/api/onboarding): Onboarding checklists for newcomers and new tenants, customized at platform, organization, and project level. (15 methods) - [root](/docs/reference/api/root): Platform root administration. (2 methods) - [trust](/docs/reference/api/trust): Cross-tenant trust for platform-controlled role assumption. (6 methods) - [sts](/docs/reference/api/sts): Temporary security credentials: short-lived sessions for assumed roles, with a duration, session policies, and tags. (3 methods) - [oidcProviders](/docs/reference/api/oidc-providers): Trusted external OpenID Connect providers whose tokens can be exchanged for temporary credentials. (6 methods) ### Access model [#access-model] What can be done to what, and who holds which permissions. - [roles](/docs/reference/api/roles): Custom roles built from permissions or conditional policies, inheritance, and role assumption. (9 methods) - [policies](/docs/reference/api/policies): Versioned JSON policies, candidate testing, simulation, and who-can / what-can reviews. (11 methods) - [bindings](/docs/reference/api/bindings): Role bindings for identities and groups, including temporary, future-dated, and just-in-time eligible bindings. (12 methods) - [groups](/docs/reference/api/groups): Groups and (optionally temporary) group memberships. (10 methods) - [authorities](/docs/reference/api/authorities): Delegated grant authorities: who may hand out which roles below them. (2 methods) - [resourceTypes](/docs/reference/api/resource-types): Tenant-defined resource types with actions, typed attributes, and relations. (5 methods) - [actions](/docs/reference/api/actions): Tenant-defined actions added to the permission catalog. (3 methods) - [resources](/docs/reference/api/resources): IAM-managed resources with owners and parents. (6 methods) - [relationships](/docs/reference/api/relationships): Relationship tuples (ReBAC) that policies read as `resource.relations` . (3 methods) ### Access lifecycle [#access-lifecycle] Granting access for a purpose and a time, and reporting on it. - [packages](/docs/reference/api/packages): Access packages: bundles of roles and memberships assigned, requested, or granted by rule. (18 methods) - [accessRequests](/docs/reference/api/access-requests): Time-boxed access requests that reviewers approve under their own authority. (7 methods) - [accessPaths](/docs/reference/api/access-paths): Self-service answers to "how can I get access?": the requests, packages, and elevations that would grant an action. (1 methods) - [reports](/docs/reference/api/reports): The access report: what ends soon, unused keys, live activations, and pending requests. (1 methods) - [config](/docs/reference/api/config): Configuration as code: export, plan, and apply a tenant’s access model by name. (3 methods) ### Governance [#governance] Reviewing, analyzing, and constraining access over time. - [certifications](/docs/reference/api/certifications): Access certification campaigns: reviewers keep or revoke access with evidence. (9 methods) - [analysis](/docs/reference/api/analysis): Access-analysis findings (dormant access, stale keys, policy lint, separation of duties) and suppressions. (4 methods) - [roleMining](/docs/reference/api/role-mining): Role mining: bundle suggestions, right-sizing, peer outliers, and usage. (6 methods) - [sod](/docs/reference/api/sod): Separation-of-duties rules enforced when access is granted. (5 methods) - [invariants](/docs/reference/api/invariants): Access invariants: guardrails that must hold whatever roles and policies say. (5 methods) - [impact](/docs/reference/api/impact): Change-impact previews: who gains and loses access before a role, policy, or binding changes. (1 methods) - [agreements](/docs/reference/api/agreements): Versioned terms of use that members accept and policies can require. (7 methods) ### Events and integrations [#events-and-integrations] The audit log, outgoing events, and service-to-service trust. - [audit](/docs/reference/api/audit): The tamper-evident audit log: search, chain verification, and JSON Lines export. (3 methods) - [webhooks](/docs/reference/api/webhooks): Signed webhook subscriptions, filters, delivery history, and redelivery. (9 methods) - [assertions](/docs/reference/api/assertions): Short-lived signed assertions for calling downstream services without sharing sessions. (1 methods) ### AI agents and models [#ai-agents-and-models] Agents as accounts, what they may do for people, and governed access to models. - [agents](/docs/reference/api/agents): AI agents as accounts: sponsors, ceilings, and a kill switch. (14 methods) - [delegations](/docs/reference/api/delegations): Agents acting on a person's behalf, within a scope and for a time. (15 methods) - [inference](/docs/reference/api/inference): Model access, provider keys, budgets, and metering. (16 methods) ### Billing [#billing] What people, teams, and organizations spend, and the budgets and statements around it. - [billing](/docs/reference/api/billing): Spend tracking for people, teams, and organizations: meters, prices, usage, budgets, credits, and statements. (38 methods) ## Instance functions [#instance-functions] Functions on the object `betterIam()` returns, outside the `api` groups. Checks such as `authorize` and `require` are what your routes call; deployment operations (migrations, scheduled jobs, secret rotation) have no HTTP routes, so you run them from a worker, a cron job, or the [CLI](/docs/reference/cli). | Function | What it does | | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | [`archiveAudit`](#archiveaudit) | Copies every tenant's new audit events, verified and in chain order, to the configured `auditArchive` sink. | | [`assertionKey`](#assertionkey) | Returns the key downstream services use to verify the assertions this deployment issues. | | [`assertionKeys`](#assertionkeys) | Returns the assertion key of the current secret followed by those of `previousSecrets`, for verifiers during a secret rotation. | | [`authenticate`](#authenticate) | Resolves a credential to the identity and session behind it, re-checking everything that could have revoked it. | | [`authorize`](#authorize) | Decides whether the caller may perform one action on one resource and returns the decision instead of throwing. | | [`authorizeMany`](#authorizemany) | Evaluates up to 50 checks for one caller in one tenant in a single transaction, for rendering UI state. | | [`bootstrap`](#bootstrap) | Creates the platform's root tenant and its first root administrator, once per installation. | | [`callPlugin`](#callplugin) | Runs a plugin endpoint from server code inside the same authorized, audited transaction as a built-in method. | | [`checkInvariants`](#checkinvariants) | Evaluates the access invariants of every active organization and records an audit event when one breaks or recovers. | | [`closeOverdueCertifications`](#closeoverduecertifications) | Closes every auto-closing certification campaign whose due date has passed and applies its decisions. | | [`closeOverdueTeamReviews`](#closeoverdueteamreviews) | Completes every open team membership review past its due date, applying its removals (a scheduler job). | | [`dispatchAuditHooks`](#dispatchaudithooks) | Runs plugin `afterAudit` hooks, `events.onEvent`, and in-process subscribers for audit events that have committed. | | [`flushAccessUsage`](#flushaccessusage) | Writes the access usage buffered in memory to storage now. | | [`initialize`](#initialize) | Creates or upgrades the database schema and runs one-time data upgrades so the instance can serve requests. | | [`listAccessible`](#listaccessible) | Lists the registered resources of one managed type that the caller may perform an action on, one page at a time. | | [`pruneAudit`](#pruneaudit) | Deletes one tenant's audit events older than a retention period while keeping the rest of the chain verifiable. | | [`purgeDeleted`](#purgedeleted) | Runs the retention worker: ends access whose time is up and removes deleted tenants past their retention window. | | [`reconcilePackages`](#reconcilepackages) | Applies access-package rules: people who match a rule receive the package, and automatic holders who stopped matching lose it. | | [`recoverRoot`](#recoverroot) | Creates an additional root administrator in the root tenant when nobody can sign in as root any more. | | [`require`](#require) | Checks one action like `authorize` and throws `ACCESS_DENIED` (403) when it is not allowed. | | [`rotateSecrets`](#rotatesecrets) | Re-seals values encrypted with a previous deployment secret using the current one, so the old secret can be retired. | | [`selfCheck`](#selfcheck) | Reports configuration and storage problems an operator should act on, each with a severity and a fix. | | [`sendAccessDigest`](#sendaccessdigest) | Emails each organization's owners its access report when the report has something in it, at most once per interval. | | [`sendExpiryReminders`](#sendexpiryreminders) | Emails each person whose access ends soon one reminder listing it, once per item and end date. | | [`sweepExpired`](#sweepexpired) | Deletes records nothing reads any more (expired sessions, protocol artifacts, old deliveries) so storage stops growing with traffic. | | [`useProtocol`](#useprotocol) | Mounts a protocol service (OAuth sign-in, the OAuth authorization server, SAML, or SCIM) so the HTTP handlers route its paths to it. | ### archiveAudit [#archiveaudit] Copies every tenant's new audit events, verified and in chain order, to the configured `auditArchive` sink. * **When:** on a schedule, every few minutes and at least hourly. * **CLI:** [`audit-archive`](/docs/reference/cli#audit-archive). * **Permission:** none. A deployment operation. * **Audited as:** not audited. Progress is kept in one archive cursor per tenant. * **Errors:** `NO_AUDIT_ARCHIVE` (501) when no `auditArchive` is configured; `INVALID_INPUT` for `limit` outside 1 to 10,000,000. * **Safe to repeat:** yes. One run at a time holds a tenant through a renewable lease; a tenant another run holds is skipped and listed under `busy`. Each run reads the events after each tenant's cursor in batches, verifies every batch against the hash the previous one ended with, hands it to your sink's `write`, and moves the cursor only after `write` resolves. A crash can therefore hand the sink the same range again, so a sink must key batches by tenant and sequence range and throw rather than replace a stored batch with different content; `createJsonlAuditArchive` does both (see [continuous audit archiving](/docs/operations/jobs#continuous-audit-archiving)). Problems do not throw. They are reported per tenant under `failed` with the code `AUDIT_CHAIN_BROKEN` (the chain does not verify, and nothing past the break is archived), `ARCHIVE_WRITE_FAILED`, or `ARCHIVE_CONFLICT`, and sequences deleted before they were archived appear under `gaps`. A run archives at most `limit` events (default 100,000); `truncated: true` means run it again. ```ts const result = await iam.archiveAudit(); if (result.failed.length) alerts.error('Audit archive is failing', result.failed); ``` ```ts title="Signature" iam.archiveAudit( input?: { tenantId?: string; limit?: number } | undefined, ): Promise ``` ### assertionKey [#assertionkey] Returns the key downstream services use to verify the assertions this deployment issues. * **When:** when you configure a downstream service. Never send it to a browser. * **Permission:** none. A deployment secret, never exposed over HTTP. [Assertions](/docs/reference/api/assertions#issue) are short-lived HS256 JSON Web Tokens that describe the caller to another service. The key is a SHA-256 derivation of the deployment `secret`, as 64 hex characters: a service that holds it verifies assertions with `verifyAssertion`, without a database round trip, and cannot recover the secret. HS256 is symmetric, so whoever holds the key can also mint assertions that every other holder accepts: give it only to services you trust and store it like a secret. It changes when `secret` changes; during a rotation, hand out `assertionKeys()` instead. ```ts // In the IAM deployment, once, to configure the reports service: const reportsKey = iam.assertionKey(); // In the reports service, which holds only that key: import { verifyAssertion } from 'better-iam'; const claims = verifyAssertion(token, { key: process.env.IAM_ASSERTION_KEY!, audience: 'reports' }); ``` ```ts title="Signature" iam.assertionKey(): string ``` ### assertionKeys [#assertionkeys] Returns the assertion key of the current secret followed by those of `previousSecrets`, for verifiers during a secret rotation. * **When:** while the deployment secret rotates. * **Permission:** none. Deployment secrets, never exposed over HTTP. New assertions are always signed with the current `secret`, but a verifier configured before a switch must accept tokens signed on either side of it while configurations roll out. `verifyAssertion`, the NestJS assertion module, and the Next.js edge verifier all accept this list. Hand it to verifiers when you introduce the new secret, and replace it with `assertionKey()` alone when you retire `previousSecrets` (see [rotating the deployment secret](/docs/operations/deployment/secrets)). If the old secret leaked, never give its key to new verifiers. ```ts // In the IAM deployment: every key, current first, for the downstream secret store. const keys = iam.assertionKeys(); // In the reports service, while the rotation is in progress: import { verifyAssertion } from 'better-iam'; const claims = verifyAssertion(token, { key: process.env.IAM_ASSERTION_KEYS!.split(','), audience: 'reports', }); ``` ```ts title="Signature" iam.assertionKeys(): string[] ``` ### authenticate [#authenticate] Resolves a credential to the identity and session behind it, re-checking everything that could have revoked it. * **When:** in the request path, when you need to know who is calling (to render a profile, choose a tenant, or pass the principal on) without checking a specific permission. * **Permission:** none. It proves identity, not access. * **Audited as:** nothing, except `auth:session:mismatch` when a session bound to its sign-in network is presented from another address. * **Errors:** `UNAUTHENTICATED` for a missing, malformed, expired, idle, or revoked credential, or a disabled or expired identity; `MFA_REQUIRED` when the session still owes MFA; `EMAIL_UNVERIFIED` when email verification is required and missing; `TENANT_UNAVAILABLE` or `TENANT_INACTIVE` when the organization is not active; `IP_NOT_ALLOWED` or `IP_BLOCKED` when network rules refuse the session. Pass `{ token }`, or the incoming `{ headers }` (the session cookie or an `Authorization: Bearer` value). Every credential kind works: user sessions, API keys, assumed-role sessions, session tokens, and IAM-signed session JWTs. Each is backed by a stored session row, so revoking the row ends the credential at its next use. When you pass headers, the client address is derived from them as the HTTP handler would (`http.clientInfo`), so network allowlists and blocks apply. The result is `{ identity, session }`; `session.kind`, `session.mfa`, and `session.tenantId` describe the caller. It checks no permission, so follow it with `authorize` or `require` before acting on data. See [sessions](/docs/guides/authentication/sessions). ```ts const { identity, session } = await iam.authenticate({ headers: request.headers }); return Response.json({ name: identity.name, tenantId: session.tenantId, mfa: session.mfa }); ``` ```ts title="Signature" iam.authenticate( input: CredentialInput, ): Promise ``` ### authorize [#authorize] Decides whether the caller may perform one action on one resource and returns the decision instead of throwing. * **When:** in the request path, before you read or change a product resource. Also served as `POST {basePath}/authorize` and `client.authorize(input)`. * **Permission:** none to call. The credential in the request (`token` or `headers`) is the principal being checked. * **Audited as:** the requested action, only for denials (outcome `deny`) and root overrides. Allowed checks are not recorded. * **Errors:** `UNAUTHENTICATED` when the credential is missing, expired, idle, or revoked; `MFA_REQUIRED` when the session still owes MFA; `TENANT_UNAVAILABLE` or `TENANT_INACTIVE` when the caller's organization or one of its ancestors is not active; `NOT_FOUND` for an unknown tenant or a managed resource that is not registered; `RESOURCE_RESOLVER_REQUIRED` for an application resource type without the `resolveResource` option; `RESOURCE_MISMATCH` when `resolveResource` returns a different resource. The result is `{ allowed, reason, matched }`. An allow carries the reason it was granted. Every denial reports `ACCESS_DENIED` with an empty `matched`, so a caller learns nothing about which rule refused it. The check reads current state in one transaction (bindings, groups, policies, [conditions](/docs/guides/authorization/conditions), boundaries, relationships, and the tenant tree), and there is no permission cache to invalidate. * An action the catalog does not know is denied, not an error. * A root administrator signed in with MFA is allowed everything (`ROOT_OVERRIDE`), and each override is audited. * Any other credential only works in its own organization: asking about another `tenantId` is denied. * An [impersonation](/docs/guides/authentication/impersonation) session is allowed only what both the member and the administrator behind it may do. * With the `accessUsage` option on, each allowed check counts as usage for [role mining](/docs/guides/governance/usage-and-mining). ```ts const decision = await iam.authorize({ headers: request.headers, tenantId, action: 'documents:write', resource: { type: 'document', id: documentId }, }); if (!decision.allowed) return new Response('Forbidden', { status: 403 }); ``` ```ts title="Signature" iam.authorize( request: AuthorizationRequest, ): Promise ``` ### authorizeMany [#authorizemany] Evaluates up to 50 checks for one caller in one tenant in a single transaction, for rendering UI state. * **When:** in the request path, typically while a page decides which buttons and menu items to show. Also served as `POST {basePath}/authorizeMany` and `client.authorizeMany(input)`. * **Permission:** none to call. Every check is evaluated for the request's credential. * **Audited as:** each check's action, for denials and root overrides only. * **Errors:** `INVALID_INPUT` for no checks, more than 50, or a check without `action`, `resource.type`, or `resource.id`; otherwise the errors of [`authorize`](#authorize). Results come back in request order as `{ action, resource, allowed, reason }`, with the same rules as `authorize`. Treat them as advisory: they say what the caller could do when the page rendered, so enforce each action again with `authorize` or `require` when it is performed. One principal validation and one transaction serve every check, which is cheaper than separate calls. Denied checks are recorded like any denied `authorize`, so a page that probes many forbidden actions writes one audit event per denial. ```ts const { results } = await iam.authorizeMany({ headers: request.headers, tenantId, checks: [ { action: 'documents:write', resource: { type: 'document', id: documentId } }, { action: 'documents:delete', resource: { type: 'document', id: documentId } }, ], }); const [canEdit, canDelete] = results.map((result) => result.allowed); ``` ```ts title="Signature" iam.authorizeMany( request: BatchAuthorizationRequest, ): Promise<{ results: BatchResult[] }> ``` ### bootstrap [#bootstrap] Creates the platform's root tenant and its first root administrator, once per installation. * **When:** once, right after the first `initialize`. * **CLI:** [`bootstrap`](/docs/reference/cli#bootstrap), which reads the `BETTER_IAM_ROOT_*` environment variables. * **Permission:** none. A deployment operation, never exposed over HTTP. * **Audited as:** `root:bootstrap`, by `deployment-operator`. * **Errors:** `ALREADY_INITIALIZED` (409) when a root tenant exists; `WEAK_PASSWORD` for a password shorter than 12 characters or refused by the password policy, `BREACHED_PASSWORD` when a configured breach check matches it; `SLUG_TAKEN` when `slug` is in use; `INVALID_INPUT` for a malformed email or an empty name. * **Safe to repeat:** yes. Every call after the first fails with `ALREADY_INITIALIZED` and changes nothing. In one transaction it creates the root tenant (named `rootName`, default "Platform", with an optional `slug` alias for sign-in screens), an identity that owns it and is a root administrator with a verified email, and an unrestricted grant authority for that identity. The result's `mfaEnrollmentRequired: true` is a reminder that root administrators always need MFA: the first sign-in enrolls a factor before the account can do anything. Read the password from a secret store; the CLI takes it from the environment so it never appears in a command line. ```ts const { tenant, identity } = await iam.bootstrap({ email: 'platform-admin@example.com', name: 'Platform administrator', password: process.env.BETTER_IAM_ROOT_PASSWORD!, slug: 'platform', }); ``` ```ts title="Signature" iam.bootstrap( input: { email: string; name: string; password: string; rootName?: string; slug?: string; }, ): Promise<{ tenant: Tenant; identity: PublicIdentity; mfaEnrollmentRequired: true }> ``` ### callPlugin [#callplugin] Runs a plugin endpoint from server code inside the same authorized, audited transaction as a built-in method. * **When:** in the request path. The same endpoint is served as `POST {basePath}/plugins/{pluginId}/{path}`, and the browser client reaches it with `client.$request('plugins/{pluginId}/{path}', input)`. * **Permission:** the endpoint's declared `action`, on `iam/{tenantId}`. * **Audited as:** the endpoint's `action`. * **Errors:** `NOT_FOUND` when no plugin with that id has a `POST` endpoint at `path`; `INVALID_INPUT` when `input.tenantId` differs from `tenantId` or the endpoint's `validate` rejects the input; `ACCESS_DENIED` without the action; plus whatever the endpoint's handler throws. [Plugins](/docs/operations/extensions) add endpoints of their own. `callPlugin` finds the endpoint by `pluginId` and `path`, runs its `validate` on `input` (with `tenantId` added), and then runs its handler in the operation envelope: the principal is re-validated, the action is authorized, plugin `beforeOperation` and `afterOperation` hooks run, and the handler's writes commit together with one audit event, or not at all. The handler's return value is returned unchanged. ```ts const project = await iam.callPlugin( { headers: request.headers }, { pluginId: 'projects', path: 'create', tenantId, input: { name: 'Website relaunch' } }, ); ``` ```ts title="Signature" iam.callPlugin( credential: CredentialInput, input: { pluginId: string; path: string; tenantId: string; input?: unknown }, ): Promise ``` ### checkInvariants [#checkinvariants] Evaluates the access invariants of every active organization and records an audit event when one breaks or recovers. * **When:** on a schedule, hourly and after configuration changes. * **CLI:** [`monitor-invariants`](/docs/reference/cli#monitor-invariants). * **Permission:** none. A deployment operation. * **Audited as:** `invariant:broken` (outcome `deny`) when an invariant starts failing or gains violators, and `invariant:restored` when it passes again, by `deployment-operator`. * **Safe to repeat:** yes. The outcome is stored on each invariant (`lastCheck`) and events are recorded only when it changes; each organization is evaluated in its own transaction. Enforced invariants already refuse changes that would break them. This job is how `monitor` invariants, and violations that predate enforcement, reach you: a webhook subscribed to `invariant:*` alerts once per change instead of every hour. The result is `{ checked, broken, restored }`. For a CI gate that fails a build, use [`invariants.run`](/docs/reference/api/invariants#run) (CLI `check-invariants`). See [change safety](/docs/guides/governance/change-safety). ```ts title="Signature" iam.checkInvariants( input?: { tenantId?: string } | undefined, ): Promise ``` ### closeOverdueCertifications [#closeoverduecertifications] Closes every auto-closing certification campaign whose due date has passed and applies its decisions. * **When:** on a schedule, hourly or daily with the other jobs. * **CLI:** [`close-certifications`](/docs/reference/cli#close-certifications). * **Permission:** none to call. Revocations run under the campaign creator's grant authority. * **Audited as:** `certification:auto-close` for each campaign, with the outcome counts, plus `iam:bindings:delete` for each removed binding, by `deployment-operator`. * **Errors:** `NOT_FOUND` for an unknown `tenantId`. * **Safe to repeat:** yes. Each campaign is re-read and closed in its own transaction, so a campaign an administrator closed meanwhile is left alone and none is closed twice. Only [campaigns](/docs/guides/governance/certifications) created with `autoClose` and a `dueAt` are affected. Revoked bindings are removed, and items nobody decided follow the campaign's `undecided` setting. When the creator no longer exists the campaign still closes, and every revocation is reported as `revocation-failed`. The result is `{ closed, skipped }`, where `skipped` counts open auto-closing campaigns that are not due yet. ```ts title="Signature" iam.closeOverdueCertifications( input?: { tenantId?: string } | undefined, ): Promise ``` ### closeOverdueTeamReviews [#closeoverdueteamreviews] Completes every open team membership review past its due date, applying its removals (a scheduler job). ```ts title="Signature" iam.closeOverdueTeamReviews( input?: { tenantId?: string } | undefined, ): Promise<{ completed: number; removed: number; failed: Array<{ reviewId: string; message: string }>; }> ``` ### dispatchAuditHooks [#dispatchaudithooks] Runs plugin `afterAudit` hooks, `events.onEvent`, and in-process subscribers for audit events that have committed. * **When:** on a schedule, every minute, in the process that registers your subscribers. It is the same function as `iam.events.dispatch()`. * **Permission:** none. * **Errors:** whatever a handler throws. The run stops there and the event stays queued. * **Safe to repeat:** yes, but delivery is at least once. A throwing handler leaves its event queued, and two dispatchers running at once can both deliver the same event, so make handlers idempotent by `event.id`. Handlers never run inside the request. The process that records an audit event queues it in the same transaction, but only when that process has an `iam.events.subscribe` handler, an `events.onEvent` option, or a plugin with `afterAudit`. This function delivers the queue oldest first and marks each row delivered, and those rows are shared by every process. The CLI's `outbox` command dispatches too, but a CLI process only has the hooks in its configuration file, so events it dispatches never reach subscribers your application registered: dispatch in the application process instead. Webhooks are separate and travel through the outbox (`iam.auth.dispatchOutbox()`). The result is `{ dispatched }`. See [lifecycle events](/docs/guides/events/lifecycle-events). ```ts iam.events.subscribe('iam:identities:*', async (event) => { await searchIndex.refreshIdentity(event.resourceId); }); setInterval(() => void iam.dispatchAuditHooks().catch(reportError), 60_000); ``` ```ts title="Signature" iam.dispatchAuditHooks(): Promise<{ dispatched: number }> ``` ### flushAccessUsage [#flushaccessusage] Writes the access usage buffered in memory to storage now. * **When:** at shutdown (for example on `SIGTERM`). Buffered usage is also written on its own every `flushIntervalMs` (one minute by default) and when `maxBuffered` pairs are waiting. * **Permission:** none. * **Audited as:** not audited. * **Safe to repeat:** yes. Concurrent calls queue behind one write, and a failed write keeps its batch for the next attempt before rethrowing. With the `accessUsage` option on, every allowed `authorize`, `authorizeMany`, `listAccessible`, and provisioning operation is counted in memory per person and action (root overrides and impersonation excepted) and written in batches, so no request waits on storage. Without a final flush, the last interval of usage is lost when the process exits. The result is `{ written }`, the number of person and action pairs written; with tracking off it writes nothing. See [usage and role mining](/docs/guides/governance/usage-and-mining). ```ts process.on('SIGTERM', async () => { await iam.flushAccessUsage(); process.exit(0); }); ``` ```ts title="Signature" iam.flushAccessUsage(): Promise<{ written: number }> ``` ### initialize [#initialize] Creates or upgrades the database schema and runs one-time data upgrades so the instance can serve requests. * **When:** once per deploy, before the new version serves traffic. * **CLI:** [`migrate`](/docs/reference/cli#migrate). * **Permission:** none. A deployment operation, never exposed over HTTP. * **Errors:** `SCHEMA_VERSION` when the database carries a schema version this release does not support. * **Safe to repeat:** yes. Every step is idempotent, and instances that migrate at the same time wait for each other's lock (up to ten minutes) instead of failing. In order, it applies the storage adapter's schema migrations, runs each plugin's `migrate` in its own transaction, starts the retention window of tenants deleted before `deletedAt` existed, and chains audit events recorded before the hash chain existed (once, in timestamp order per tenant). Some upgrades build indexes inside the migration transaction, which blocks IAM writes (not reads) on large tables while it runs, and the first run after upgrading to the chained audit log backfills it in one transaction. Plan those deploys for a maintenance window; see [storage](/docs/operations/storage). ```ts // In a release step, before the new version starts serving. await iam.initialize(); ``` ```ts title="Signature" iam.initialize(): Promise ``` ### listAccessible [#listaccessible] Lists the registered resources of one managed type that the caller may perform an action on, one page at a time. * **When:** in the request path, for list pages such as "my workspaces". Also served as `POST {basePath}/listAccessible` and `client.listAccessible(input)`. * **Permission:** none to call. Results are filtered for the request's credential. * **Audited as:** not audited. * **Errors:** `INVALID_ACTION` for an action the catalog does not know; `INVALID_RESOURCE_TYPE` for an unknown type or one your application resolves itself; `INVALID_INPUT` for `limit` outside 1 to 1000 or `offset` outside 0 to 1,000,000; `NOT_FOUND` for an unknown tenant; plus the credential errors of [`authorize`](#authorize). It is the reverse of `authorize`: instead of asking about one resource, it evaluates every [registered resource](/docs/guides/concepts/resources-and-catalog) of `type` against the caller's grants, boundaries, and conditions, which are loaded once. `total` counts every accessible resource and `resources` holds the requested page (`limit` defaults to 100, `offset` to 0) in a stable order. A root administrator sees them all, an impersonation session sees only what the administrator behind it could reach, and a caller from another organization gets an empty list. It only works for managed types, because application-resolved resources are not stored in IAM. Like `authorizeMany`, the list is advisory: check the action again when the person opens an item. ```ts const { resources, total } = await iam.listAccessible({ headers: request.headers, tenantId, action: 'workspaces:read', type: 'workspace', limit: 25, offset: 0, }); ``` ```ts title="Signature" iam.listAccessible( request: AccessibleResourcesRequest, ): Promise<{ resources: ResourceRecord[]; total: number }> ``` ### pruneAudit [#pruneaudit] Deletes one tenant's audit events older than a retention period while keeping the rest of the chain verifiable. * **When:** on a schedule that matches your retention policy, after the events were archived or exported. * **CLI:** [`audit-prune`](/docs/reference/cli#audit-prune). * **Permission:** none. A deployment operation, with no tenant check. * **Audited as:** `audit:prune` on the tenant, by `deployment-operator`, with the number deleted, the cutoff, and the sequence and hash the chain now starts after. * **Errors:** `INVALID_INPUT` for a missing `tenantId` or a `retentionMs` outside 0 to 100 years. * **Safe to repeat:** yes. It runs in one transaction; a rerun with the same retention deletes only events that have aged since, and records nothing when nothing was deleted. It deletes the longest run of oldest events, in chain order, whose timestamps are before now minus `retentionMs`, stopping at the first newer event, then appends the `audit:prune` checkpoint so [chain verification](/docs/guides/events/audit-chain) starts after the pruned prefix. Once a tenant has an archive cursor, or wherever `auditArchive` is configured, it deletes only events the archive already holds, in every process, and returns `heldForArchive: true` when it stopped early for that reason. Pruned events are gone from the database, so archive or export them first. ```ts const { deleted, heldForArchive } = await iam.pruneAudit({ tenantId, retentionMs: 365 * 24 * 60 * 60 * 1000, }); ``` ```ts title="Signature" iam.pruneAudit( input: { tenantId: string; retentionMs: number }, ): Promise<{ deleted: number; prunedThroughSequence?: number; prunedThroughHash?: string; heldForArchive?: true; }> ``` ### purgeDeleted [#purgedeleted] Runs the retention worker: ends access whose time is up and removes deleted tenants past their retention window. * **When:** on a schedule, hourly or at least daily. * **CLI:** [`purge`](/docs/reference/cli#purge). * **Permission:** none. A deployment operation. * **Audited as:** `identity:expire` for each identity it disables and `tenants:purge` for each purged tenant tree, by `deployment-operator`. Audit records of purged tenants are kept. * **Errors:** `INVALID_INPUT` when `retentionMs` is outside 0 to ten years. * **Safe to repeat:** yes. Authentication bookkeeping is deleted in short batches that re-read each row, and the rest runs in one transaction, so a second or overlapping run finds nothing left to do. In one run it: * deletes expired temporary role bindings with their activations, and ended just-in-time activations; * removes lapsed temporary group memberships (with the activations of the group's eligible bindings they carried) and access-package assignments past their end; * marks pending access requests and package requests past their lifetime as `expired`; * disables identities past their scheduled deactivation (`expiresAt`), revoking their sessions and activations; * deletes rate-limit counters past their window, expired challenges, and lapsed network blocks; * deletes every record of tenants tombstoned more than `retentionMs` ago (default 30 days), their descendants, plugin-owned records through plugin `purge` callbacks, and references to them from surviving tenants. Expired access is refused at its next use even before the worker runs, so the schedule only decides how quickly statuses, lists, and reports catch up. The result counts `purgedTenants`, `deletedRecords`, `expiredBindings`, `expiredRequests`, `expiredIdentities`, `expiredActivations`, `expiredMemberships`, and `expiredAssignments`. See [access lifecycle](/docs/guides/privileged-access/lifecycle). ```ts const result = await iam.purgeDeleted({ retentionMs: 30 * 24 * 60 * 60 * 1000 }); ``` ```ts title="Signature" iam.purgeDeleted( input?: { retentionMs?: number } | undefined, ): Promise ``` ### reconcilePackages [#reconcilepackages] Applies access-package rules: people who match a rule receive the package, and automatic holders who stopped matching lose it. * **When:** on a schedule, every 15 minutes, after `purgeDeleted`. * **CLI:** [`reconcile`](/docs/reference/cli#reconcile). * **Permission:** none to call. Each change runs under the rule owner's grant authority, so a rule never grants more than its owner could. * **Audited as:** `package:auto-assign`, `package:auto-ending`, and `package:auto-revoke` for each change, plus `package:auto-suspended`, `package:auto-braked`, `package:auto-failed`, `package:auto-resumed`, and `package:auto-confirm` for rule problems and confirmations. * **Errors:** `INVALID_INPUT` for a `packageId` without `tenantId`, `confirm` without `packageId`, or a `limit` outside 1 to 10,000; `NOT_FOUND` for an unknown `tenantId`. * **Safe to repeat:** yes. It plans from plain reads and applies each change in its own transaction; a change whose inputs moved in between is counted as `stale` and retried on the next run. This is how [automatic assignment](/docs/guides/privileged-access/automatic-assignment) (birthright access) keeps up with SCIM provisioning, invitations, attribute changes, and group changes. Removals run first and honor the rule's grace period (`ending`). A run makes at most `limit` changes per organization (default 1000); `truncated: true` means run it again. Scheduled runs hold back unusually large changes (listed under `braked`) until a person confirms them in the console or with `confirm: true` for one `packageId`. A rule that cannot be evaluated is `suspended` instead of revoking everyone, and an organization that fails is listed under `skipped.failedTenants` without stopping the others. ```ts const result = await iam.reconcilePackages(); if (result.failed.length || result.suspended.length || result.braked.length) await notifyAdmins(result); ``` ```ts title="Signature" iam.reconcilePackages( input?: | { tenantId?: string; packageId?: string; limit?: number; confirm?: boolean } | undefined, ): Promise ``` ### recoverRoot [#recoverroot] Creates an additional root administrator in the root tenant when nobody can sign in as root any more. * **When:** during an incident, from a trusted shell with the production configuration. * **CLI:** [`recover-root`](/docs/reference/cli#recover-root), which reads the `BETTER_IAM_ROOT_*` environment variables. * **Permission:** none. A deployment operation, never exposed over HTTP: access to the database and configuration is the authority. * **Audited as:** `root:recover`, by `deployment-operator`. * **Errors:** `NOT_INITIALIZED` before `bootstrap` has run; `IDENTITY_EXISTS` when the email already belongs to an identity in the root tenant; `WEAK_PASSWORD` or `BREACHED_PASSWORD` for a password the policy refuses. * **Safe to repeat:** each call adds another administrator and another `root:recover` event. It does not reset or unlock existing administrators. It adds a new identity with a verified email and the root administrator flag, which enrolls MFA at its first sign-in, so use an email address that is not in the root tenant yet. Once you are back in, review the [root administrators](/docs/reference/api/root#listadministrators) and repair or remove the lost account. Treat it as a break-glass procedure and alert on `root:recover` events. ```ts title="Signature" iam.recoverRoot( input: { email: string; name: string; password: string }, ): Promise<{ identity: PublicIdentity; tenantId: string; mfaEnrollmentRequired: true }> ``` ### require [#require] Checks one action like `authorize` and throws `ACCESS_DENIED` (403) when it is not allowed. * **When:** in the request path, as a guard at the top of a route handler or server action. * **Permission:** none to call. The request's credential is checked. * **Audited as:** the requested action, for denials and root overrides. * **Errors:** `ACCESS_DENIED` when the decision is a denial; otherwise the errors of [`authorize`](#authorize). Use `require` when a denial should end the request, and `authorize` when you want to branch on the decision. Framework guards and middleware call it for you. Map the thrown `IamError` to a response by its `code`, never its message. ```ts await iam.require({ headers: request.headers, tenantId, action: 'invoices:approve', resource: { type: 'invoice', id: invoiceId }, }); ``` ```ts title="Signature" iam.require( request: AuthorizationRequest, ): Promise ``` ### rotateSecrets [#rotatesecrets] Re-seals values encrypted with a previous deployment secret using the current one, so the old secret can be retired. * **When:** during a secret rotation, after every process runs with the new `secret` and the old value in `previousSecrets`. * **CLI:** [`rotate-secrets`](/docs/reference/cli#rotate-secrets). * **Permission:** none. A deployment operation. * **Audited as:** not audited. * **Errors:** `INVALID_INPUT` for a `batchSize` outside 1 to 2000 or a `limit` that is not a positive integer. * **Safe to repeat:** yes. It works a page at a time in short transactions that re-read each record, so you can stop it and run it again. It rewrites TOTP authenticator secrets, webhook signing secrets, and the sealed payloads of undelivered outbox messages. Sessions, API keys, and invitation links do not depend on the secret, so nobody is signed out. `dryRun: true` only counts. The result has `resealed` and `unreadable` counts per collection, `current` (values already sealed with the current secret), `complete`, and `done`. Repeat until `done` is true, wait a day for emailed links and assertions issued under the old secret to expire, then remove `previousSecrets` everywhere. `unreadable` values open with no configured secret, which means the secret was replaced without listing the old one: put it back into `previousSecrets` and run again. See [rotating the deployment secret](/docs/operations/deployment/secrets). ```ts const preview = await iam.rotateSecrets({ dryRun: true }); if (!preview.done) await iam.rotateSecrets(); ``` ```ts title="Signature" iam.rotateSecrets( options?: SecretRotationOptions | undefined, ): Promise ``` ### selfCheck [#selfcheck] Reports configuration and storage problems an operator should act on, each with a severity and a fix. * **When:** after each deploy as a gate, and on a schedule as a health check. It only reads. * **CLI:** [`doctor`](/docs/reference/cli#doctor). * **Permission:** none. A deployment operation. * **Errors:** `INVALID_INPUT` for `cap` outside 1 to 100,000 or a retention option out of range. It returns `{ ok, findings, storage }`. `ok` is false when any finding is an `error`. `storage` is the adapter's own description: schema version, applied migrations, record counts, and durability settings. Each finding has a stable `check` id, a `severity`, a `message`, a `fix`, and sometimes a `count`: * **Errors:** `schema-behind`, `not-bootstrapped`, `sqlite-durability` (a rollback journal with `synchronous` at NORMAL or OFF), and `unreadable-secrets`. * **Warnings:** `in-memory-database`, `postgres-async-commit`, `weak-secret`, `weak-metrics-token`, `no-email-transport`, `secret-rotation-pending`, `secret-rotation-unverified`, and jobs that are not running: `sweep-backlog` (records due for more than two days), `purge-not-running` (expired bindings, memberships, or challenges over a day old), `outbox-stalled` (messages waiting over 15 minutes), `outbox-abandoned` (messages abandoned in the last day), `audit-hooks-stalled` (hooks waiting over 15 minutes), and `audit-archive-behind` (unarchived events older than a day, with `auditArchive`). * **Info:** `storage-undescribed` and `previous-secrets-configured`. Pass the `deliveryRetentionMs` and `graceMs` your sweep runs with so its backlog is judged the same way. `cap` (default 1000) bounds how many records each check counts and how many sealed values per collection it samples. ```ts const { ok, findings } = await iam.selfCheck({ deliveryRetentionMs: 30 * 24 * 60 * 60 * 1000 }); if (!ok) throw new Error(findings.map((finding) => `${finding.check}: ${finding.fix}`).join('\n')); ``` ```ts title="Signature" iam.selfCheck( options?: SelfCheckOptions | undefined, ): Promise ``` ### sendAccessDigest [#sendaccessdigest] Emails each organization's owners its access report when the report has something in it, at most once per interval. * **When:** on a schedule, daily, followed by an outbox run that delivers the messages. * **CLI:** [`digest`](/docs/reference/cli#digest). * **Permission:** none. A deployment operation that includes every report section. * **Audited as:** `tenant:access-digest` on each organization emailed, by `deployment-operator`, with the recipient and finding counts. * **Errors:** `DELIVERY_REQUIRED` when `authentication.sendEmail` is not configured; `NOT_FOUND` for an unknown `tenantId`; `INVALID_INPUT` for a window out of range. * **Safe to repeat:** yes. An organization digested within `minimumIntervalMs` (default 20 hours) is skipped, so reruns and overlapping schedules never email twice. For every active organization, or one `tenantId`, it builds the [access report](/docs/guides/privileged-access/access-report): identities and temporary bindings ending within `withinMs` (default 30 days), memberships ending soon, live just-in-time activations, pending activation requests, and API keys unused for `unusedForMs` (default 30 days) or ending soon. Each active owner with an email address of an organization with findings is sent one `access-digest` message with the counts and the full report as JSON. The result lists `sent` per organization and `skipped` counts (`inactive`, `recent`, `quiet`, `noOwners`). ```ts await iam.sendAccessDigest({ withinMs: 14 * 24 * 60 * 60 * 1000 }); await iam.auth.dispatchOutbox(); ``` ```ts title="Signature" iam.sendAccessDigest( input?: | { tenantId?: string; withinMs?: number; unusedForMs?: number; minimumIntervalMs?: number; } | undefined, ): Promise ``` ### sendExpiryReminders [#sendexpiryreminders] Emails each person whose access ends soon one reminder listing it, once per item and end date. * **When:** on a schedule, daily beside `sendAccessDigest`, followed by an outbox run. * **CLI:** [`remind`](/docs/reference/cli#remind). * **Permission:** none. A deployment operation. * **Audited as:** `identity:expiry-reminder` on each person emailed, by `deployment-operator`, with the reminded items. * **Errors:** `DELIVERY_REQUIRED` when `authentication.sendEmail` is not configured; `NOT_FOUND` for an unknown `tenantId`; `INVALID_INPUT` for a `withinMs` outside one minute to 365 days. * **Safe to repeat:** yes. A reminder mark per item and end date prevents repeats. It looks at every active person in every active organization, or one `tenantId`, and collects what ends within `withinMs` (default seven days): their own account (`expiresAt`), direct role bindings, temporary group memberships, and access-package assignments. Bindings and memberships that belong to a package are reminded as the package, group bindings are left to the owners' digest, and eligible (just-in-time) bindings are skipped. Each person with something new gets one `expiry-reminder` email with `items` as JSON (kind, name, and end). Extending access brings a fresh reminder when the new end enters the window. People without an email address are skipped. ```ts const { sent } = await iam.sendExpiryReminders({ withinMs: 3 * 24 * 60 * 60 * 1000 }); ``` ```ts title="Signature" iam.sendExpiryReminders( input?: { tenantId?: string; withinMs?: number } | undefined, ): Promise ``` ### sweepExpired [#sweepexpired] Deletes records nothing reads any more (expired sessions, protocol artifacts, old deliveries) so storage stops growing with traffic. * **When:** on a schedule, hourly or daily, beside `purgeDeleted`. * **CLI:** [`sweep`](/docs/reference/cli#sweep). * **Permission:** none. A deployment operation. * **Audited as:** not audited. * **Errors:** `INVALID_INPUT` for an option outside its range. * **Safe to repeat:** yes. Each batch is read and deleted in its own short transaction, and records are judged on their current state, so it can run during traffic and beside another run. It deletes user sessions, role sessions, and session tokens past their absolute expiry; trusted devices and relationship tuples past expiry; redeemed web-identity tokens once they could no longer be presented; OAuth artifacts and login states (OAuth grants 31 days after expiry, so back-channel logout still reaches the client); SAML request, relay-state, and assertion-replay records; delivered or abandoned outbox messages and abandoned Shared Signals deliveries older than `deliveryRetentionMs`; and audit hook rows already dispatched. API keys, pending deliveries, invitations, access requests, usage records, and SCIM connections are never deleted by age. Options: `limit` (default 10,000 records per run, at most 1,000,000), `batchSize` (500 per transaction, 1 to 5000), `deliveryRetentionMs` (30 days, 0 to 3650 days), `graceMs` (a five-minute margin past expiry against clock skew, at most one day), and `now` (for tests and backfills). The result is `{ deleted, total, truncated }` with counts per collection; `truncated: true` means more records are due, so run it again. Pass the same `deliveryRetentionMs` to [`selfCheck`](#selfcheck) so its backlog check agrees. ```ts const { total, truncated } = await iam.sweepExpired({ deliveryRetentionMs: 14 * 24 * 60 * 60 * 1000 }); ``` ```ts title="Signature" iam.sweepExpired( options?: SweepOptions | undefined, ): Promise ``` ### useProtocol [#useprotocol] Mounts a protocol service (OAuth sign-in, the OAuth authorization server, SAML, or SCIM) so the HTTP handlers route its paths to it. * **When:** once per service at process start, before the handler serves requests. * **Permission:** none. The mounted service authenticates its own requests. A protocol service is `{ basePath, handler, nodeHandler }` (either handler optional), as returned by `createScimService`, `createSamlService`, `createOAuthLogin`, or `createOAuthProvider`, which you build from the `iam.protocolHost` callbacks. `handler` asks each mounted service in mount order and uses the first response. `nodeHandler` also offers requests under a service's `basePath` to that service's Node handler, which the OAuth authorization server requires, so serve it through `iam.nodeHandler`. When a mounted service returns a session (a SAML or OAuth sign-in), the handler sets the IAM session cookie, and every mount runs with the request's client address, so network allowlists and blocks apply to the sessions it issues. There is no unmount, so call it once per service. See [protocol mounts](/docs/operations/deployment/protocol-mounts). ```ts import { createScimService } from 'better-iam/scim'; const scim = createScimService({ ...iam.protocolHost }); iam.useProtocol(scim); ``` ```ts title="Signature" iam.useProtocol( protocol: ProtocolService, ): void ``` # inference (/docs/reference/api/inference) > Decides which people, service accounts, and AI agents may call which AI models, caps what they spend, and meters every call. Decides which people, service accounts, and AI agents may call which AI models, caps what they spend, and meters every call. Administrators register upstream providers (whose API keys are sealed and never returned), publish models under public names, and set budgets. Calling a model is an ordinary policy decision, `inference:invoke` on `model/{name}`, so roles, conditions, boundaries, and [delegations](/docs/reference/api/delegations) apply as everywhere else. Callers reach models through the gateway `iam.inference.gateway()` builds, which keeps provider keys on the server, or through an external gateway that uses `check` and `record`. The group needs the `inference` server option; without it every method fails with `FEATURE_DISABLED`. The repository guide is `docs/inference.md`. ## Permissions [#permissions] | Action | Allows | | ------------------------------------------- | ------------------------------------------------------------------------------ | | `inference:invoke` on `model/{name}` | Calling a model. | | `inference:use-tool` on `model-tool/{kind}` | A tool the provider runs itself, for models whose `providerTools` is `policy`. | | `iam:inference:manage` | Managing providers, models, and budgets. | | `iam:inference:read` | Listing providers, models, and budgets, and reading usage reports. | | `iam:inference:record` | Metering calls made for others with check tickets (external gateways). | The `iam:*` actions are checked on the tenant, except that provider changes and budget deletion are checked on `iam/{providerId}` and `iam/{budgetId}`. `check`, `listMine`, and `myUsage` need no `iam:*` permission: any credential may call them for itself. ## Models, inheritance, and policies [#models-inheritance-and-policies] Models are published per tenant and inherited by sub-tenants: a platform defines providers and models once at the root tenant and every organization sees them, while an organization's own model of the same name takes precedence for it. Conditions can use a model's attributes: `resource.provider` (the provider's name), `resource.providerKind`, `resource.upstreamModel`, `resource.family`, `resource.tier`, `resource.contextWindow`, `resource.inputPricePerMTok`, `resource.outputPricePerMTok`, and `resource.enabled`. ```json { "effect": "allow", "actions": ["inference:invoke"], "resources": ["model/*"], "conditions": { "StringEquals": { "resource.tier": "small" } } } ``` Tools the provider runs itself (web search, code execution, file search, image generation, computer use, remote MCP servers) follow the model's `providerTools`: * `allow` (the default) passes them. * `deny` refuses any request that asks for one. * `policy` decides each one as `inference:use-tool` on `model-tool/{kind}`, such as `model-tool/web_search` or `model-tool/mcp:kb.acme.com`, with `resource.kind` and `resource.host`. A Responses `prompt` (a stored template that may bring its own tools) counts as the tool `prompt`, and an MCP server URL that is not in plain canonical form reads as `mcp:invalid`. Functions the caller runs itself are never checked. A refusal has the reason `TOOL_NOT_ALLOWED` and names the tool. ## Budgets and metering [#budgets-and-metering] A budget caps tokens (`maxTokens`), cost (`maxCostUsd`), calls (`maxRequests`), or any combination per `minute`, `hour`, `day`, or `month`, in UTC windows; a `minute` budget is a requests- or tokens-per-minute rate limit. `subjectType` says whom it covers: `tenant` (every call in the tenant), `group` (the group's members), or `identity` (one identity; for an agent, everything it does, with its own key and in every delegated session). `scope: 'shared'` makes one pool for everyone covered, and `scope: 'each'` gives every covered identity the full amount (identity budgets are always shared). `models` limits a budget to some model name patterns. A call is refused with the reason `BUDGET_EXCEEDED` when a covering budget is spent or the call's estimate would not fit in what is left. The first refusal in a window is audited as `inference:budget-exceeded`, and crossing a budget's `alertAtPercent` is audited once per window as `inference:budget-alert`. Cost is metered in micro-dollars: tokens times the model's price per million tokens, with cache reads at `cachedInputPricePerMTok` when set and cache writes at the input price. Budgets count input, output, and cache tokens. Usage records are kept for the `usageRetentionDays` option (90 days by default) and budget counters for 35 days after their window; the expiry sweep ([`sweepExpired`](/docs/reference/api#sweepexpired)) deletes them after that. ## Gateway and server runtime [#gateway-and-server-runtime] `iam.inference` is the server-side half, outside the `api` groups: * `iam.inference.gateway(options)` returns an HTTP handler (a `Request` to `Response` function). It serves Anthropic Messages (`POST /v1/messages`) and token counting (`POST /v1/messages/count_tokens`, checked but not metered). It serves OpenAI Chat Completions (`POST /v1/chat/completions`), Responses (`POST /v1/responses`) and Embeddings (`POST /v1/embeddings`), and `GET /v1/models`. Callers send any Better IAM credential as the API key (`Authorization: Bearer` or `x-api-key`). For each call the gateway checks `inference:invoke`, the budgets and the provider-run tools the request asks for. It replaces the public model name with the upstream one, calls the provider with the sealed key, streams the answer back unchanged, and meters the tokens the provider reports. Refusals use each API's own error format: * 401; * 403 (`ACCESS_DENIED`, `MODEL_DISABLED`, `TOOL_NOT_ALLOWED`); * 404, including `RESPONSE_NOT_FOUND` for a `previous_response_id` the caller did not create; * 413 for a body over `maxBodyBytes`; * 429 (`BUDGET_EXCEEDED`, with `Retry-After` until the window resets); * 400 when a model is called in the other provider's format, for more than 256 tools or 64 MCP servers, and `UNSUPPORTED_PARAMETER` for `conversation` or `background` on `/v1/responses` and for references to objects stored at the provider (`item_reference` inputs, `file_id`s, Anthropic `file` sources, existing containers); * 502 when the provider cannot be reached. Options: `basePath`, `fetch`, `timeoutMs` (10 minutes), `anthropicVersion`, `maxBodyBytes` (20 MiB), and `onError`. * `iam.inference.authorize(credential, { model, tools })` runs the same check for code that calls providers itself and returns either a permit with the opened provider key (never send it to a client) or `{ denied }`. * `iam.inference.record(permit, usage)` meters a call made under a permit. * `iam.inference.models(credential)` lists the models a credential may use, like `listMine`. ```ts const gateway = iam.inference.gateway({ basePath: '/ai' }); // Hono: app.all('/ai/*', (c) => gateway(c.req.raw)); // Clients: new Anthropic({ baseURL: 'https://app.example.com/ai', apiKey: betterIamToken }) ``` | Method | What it does | Access | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`check`](#check) | Tells the caller whether they may invoke a model now and, when they may, returns a single-use ticket for an external gateway. | Credential | | [`createModel`](#createmodel) | Publishes a model under a public name, served by a provider of this tenant or an ancestor. | Credential | | [`createProvider`](#createprovider) | Registers an upstream model provider account, whose API key is sealed and never returned. | Credential | | [`deleteBudget`](#deletebudget) | Deletes a budget together with its usage counters. | Credential | | [`deleteModel`](#deletemodel) | Removes one of this tenant's models; an inherited model of the same name becomes visible again. | Credential | | [`deleteProvider`](#deleteprovider) | Deletes a provider that no model uses, together with its sealed key. | Credential | | [`listBudgets`](#listbudgets) | Lists the tenant's budgets by name, with the current window's standing of each shared pool. | Credential | | [`listMine`](#listmine) | Lists the enabled models the caller may invoke, for a model picker. | Credential | | [`listModels`](#listmodels) | Lists every model the tenant can see, its own and inherited ones, by name. | Credential | | [`listProviders`](#listproviders) | Lists the providers this tenant's models may use: its own, then its ancestors'. | Credential | | [`myUsage`](#myusage) | Returns the caller's own usage by model and the standing of every budget that covers them. | Credential | | [`record`](#record) | Meters a call an external gateway made after `check`, by redeeming the check's ticket. | Credential | | [`setBudget`](#setbudget) | Creates a budget, or replaces one when `budgetId` is given. | Credential | | [`updateModel`](#updatemodel) | Changes one of this tenant's models, found by its name. | Credential | | [`updateProvider`](#updateprovider) | Renames a provider, moves its base URL, or replaces its API key. | Credential | | [`usage`](#usage) | Reports model calls between two times, grouped by identity, agent, model, or day. | Credential | ## check [#check] Tells the caller whether they may invoke a model now and, when they may, returns a single-use ticket for an external gateway. **HTTP:** `POST /api/iam/inference/check` (requires a credential) · **Browser client:** `client.inference.check()` * **Permission:** None: any credential, for itself. The model decision is `inference:invoke` on `model/{name}`. * **Audited as:** Denials as `inference:invoke` with outcome `deny`, like any decision; the first budget refusal in a window as `inference:budget-exceeded`. * **Errors:** `NOT_FOUND` for a model the tenant cannot see; `INVALID_INPUT` for a malformed model name, an `estimatedTokens` outside 0 to 100 000 000, or `tools` that are not at most 64 provider tool ids; `FEATURE_DISABLED` without the `inference` option. The answer is `{ allowed: true, model, budgets, ticket }`, or `{ allowed: false, reason, model }`. The `reason` is one of: * `ACCESS_DENIED`; * `MODEL_DISABLED`: the model or its provider is off; * `TOOL_NOT_ALLOWED`: with the refused `tool`, when the model's `providerTools` does not allow one of `tools`; * `BUDGET_EXCEEDED`: with the exhausted `budget`'s standing. `tools` lists the provider-run tools the call will ask for, as ids such as `web_search` or `mcp:kb.acme.com` (`providerToolsOf` from `better-iam` reads them from a request body). `estimatedTokens`, priced at the model's input price, must fit in every covering budget. The `ticket` is valid for one hour and names the caller, their session, and the model: an external gateway makes the call and then redeems it with `record`. ```ts const result = await iam.api.inference.check(credential, { tenantId, model: 'opus', estimatedTokens: 4_000 }); if (!result.allowed) return refuse(result.reason); // Call the provider, then report the tokens with result.ticket through inference.record. ``` ```ts title="Signature" iam.api.inference.check( credential: CredentialInput, input: { tenantId: string; model: string; estimatedTokens?: number; tools?: string[]; }, ): Promise ``` ## createModel [#createmodel] Publishes a model under a public name, served by a provider of this tenant or an ancestor. **HTTP:** `POST /api/iam/inference/createModel` (requires a credential) · **Browser client:** `client.inference.createModel()` * **Permission:** `iam:inference:manage` on the tenant. * **Audited as:** `iam:inference:manage`. * **Errors:** `CONFLICT` (409) when this tenant already has a model of that name; `NOT_FOUND` when the provider is not this tenant's or an ancestor's; `INVALID_INPUT` for a malformed name, a `family` or `tier` that is not a short identifier, a `contextWindow` or `maxOutputTokens` outside 1 to 100 000 000, a price outside 0 to 10 000, `fallbacks` naming more than five models or the model itself, or a `providerTools` other than `allow`, `deny`, or `policy`. The `name` (1 to 128 letters, digits, or `._:/@+-`, starting with a letter or digit) is what callers send and what policies name as `model/{name}`; `upstreamModel` is the provider's own name for it. Prices are US dollars per million tokens and drive cost metering and cost budgets; a model without prices costs nothing. `tier` and `family` are free-form attributes for policies. `maxOutputTokens` caps the output of every gateway call. `fallbacks` lists models the gateway tries, in order, when this one's provider cannot be reached or answers 429, 500, 502, 503, 504, or 529; each one only if the caller may call it and it speaks the same wire format. `providerTools` (`allow` by default, `deny`, or `policy`) governs the tools the provider runs itself (see [Models, inheritance, and policies](#models-inheritance-and-policies)). A new model starts enabled, and sub-tenants inherit it. ```ts await iam.api.inference.createModel(credential, { tenantId, name: 'opus', providerId: anthropic.id, upstreamModel: 'claude-opus-5-5', tier: 'frontier', family: 'claude', inputPricePerMTok: 5, outputPricePerMTok: 25, cachedInputPricePerMTok: 0.5, }); ``` ```ts title="Signature" iam.api.inference.createModel( credential: CredentialInput, input: ModelInput & { tenantId: string; name: string; providerId: string; upstreamModel: string; }, ): Promise ``` ## createProvider [#createprovider] Registers an upstream model provider account, whose API key is sealed and never returned. **HTTP:** `POST /api/iam/inference/createProvider` (requires a credential) · **Browser client:** `client.inference.createProvider()` * **Permission:** `iam:inference:manage` on the tenant, and a recent sign-in. * **Audited as:** `iam:inference:manage`. * **Errors:** `CONFLICT` (409) for a name another provider of the tenant uses (ignoring case); `ACCESS_DENIED` for a custom `baseUrl` from anyone but a root administrator, unless the deployment sets `inference.allowCustomBaseUrls`; `INVALID_INPUT` for an unknown `kind`, an `openai-compatible` provider without `baseUrl`, an `apiKey` that is not 8 to 4096 characters without whitespace, or a `baseUrl` that is not https or carries credentials, a query, or a fragment; `RECENT_AUTH_REQUIRED` without a recent sign-in. `kind` is `anthropic`, `openai`, or `openai-compatible` (any endpoint that speaks the OpenAI Chat Completions API). The first two default to the provider's public endpoint. A custom base URL makes the gateway send the key to that address, hence the root rule. The key is sealed with the deployment secret, bound to this provider, and opened only inside the server; the result shows `keyHint`, its last four characters. Models of this tenant and its sub-tenants may use the provider. [`rotateSecrets`](/docs/reference/api#rotatesecrets) re-seals provider keys under a new secret. ```ts const anthropic = await iam.api.inference.createProvider(credential, { tenantId, name: 'Anthropic', kind: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY!, }); ``` ```ts title="Signature" iam.api.inference.createProvider( credential: CredentialInput, input: { tenantId: string; name: string; kind: ProviderKind; apiKey: string; baseUrl?: string; }, ): Promise ``` ## deleteBudget [#deletebudget] Deletes a budget together with its usage counters. **HTTP:** `POST /api/iam/inference/deleteBudget` (requires a credential) · **Browser client:** `client.inference.deleteBudget()` * **Permission:** `iam:inference:manage` on the budget. * **Audited as:** `iam:inference:manage`. * **Errors:** `NOT_FOUND` when the budget is not in this tenant. Calls it covered are no longer capped by it. Usage records are kept, so reports still show the spending. ```ts title="Signature" iam.api.inference.deleteBudget( credential: CredentialInput, input: { tenantId: string; budgetId: string }, ): Promise<{ deleted: boolean }> ``` ## deleteModel [#deletemodel] Removes one of this tenant's models; an inherited model of the same name becomes visible again. **HTTP:** `POST /api/iam/inference/deleteModel` (requires a credential) · **Browser client:** `client.inference.deleteModel()` * **Permission:** `iam:inference:manage` on the tenant. * **Audited as:** `iam:inference:manage`. * **Errors:** `NOT_FOUND` when this tenant defines no model of that name (an inherited model is deleted in the tenant that defines it). Callers asking for the name afterwards get `NOT_FOUND` unless an ancestor defines it. To stop calls without deleting, use `updateModel` with `enabled: false`. ```ts title="Signature" iam.api.inference.deleteModel( credential: CredentialInput, input: { tenantId: string; name: string }, ): Promise<{ deleted: boolean }> ``` ## deleteProvider [#deleteprovider] Deletes a provider that no model uses, together with its sealed key. **HTTP:** `POST /api/iam/inference/deleteProvider` (requires a credential) · **Browser client:** `client.inference.deleteProvider()` * **Permission:** `iam:inference:manage` on the provider. * **Audited as:** `iam:inference:manage`. * **Errors:** `RESOURCE_IN_USE` (409) while any model uses it, a sub-tenant's included; `NOT_FOUND` when the provider is not this tenant's own. Point the models at another provider with `updateModel`, or delete them, first. ```ts title="Signature" iam.api.inference.deleteProvider( credential: CredentialInput, input: { tenantId: string; providerId: string }, ): Promise<{ deleted: boolean }> ``` ## listBudgets [#listbudgets] Lists the tenant's budgets by name, with the current window's standing of each shared pool. **HTTP:** `POST /api/iam/inference/listBudgets` (requires a credential) · **Browser client:** `client.inference.listBudgets()` * **Permission:** `iam:inference:read` on the tenant. * **Audited as:** `iam:inference:read`. Each budget carries `maxCostUsd` next to the stored `maxCostMicros`, `subjectName` (the group's name, or the identity's email or name), and, for `shared` and `identity` budgets, `standing`: the window's start, `resetsAt`, and the tokens and cost used and remaining. An `each` budget has one pool per identity; people see theirs with `myUsage`. ```ts title="Signature" iam.api.inference.listBudgets( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listMine [#listmine] Lists the enabled models the caller may invoke, for a model picker. **HTTP:** `POST /api/iam/inference/listMine` (requires a credential) · **Browser client:** `client.inference.listMine()` * **Permission:** None: any credential, for itself. * **Audited as:** Not audited; the decisions are evaluated without being recorded. * **Errors:** `FEATURE_DISABLED` without the `inference` option. Each model is decided like an `inference:invoke` call, so a delegated agent session sees only what the person may use within the delegation. Budgets are not considered; `check` does that before a call. ```ts title="Signature" iam.api.inference.listMine( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listModels [#listmodels] Lists every model the tenant can see, its own and inherited ones, by name. **HTTP:** `POST /api/iam/inference/listModels` (requires a credential) · **Browser client:** `client.inference.listModels()` * **Permission:** `iam:inference:read` on the tenant. * **Audited as:** `iam:inference:read`. When a tenant and an ancestor define the same name, only the nearest definition appears. `inherited` marks models an ancestor defines, and `enabled` is false when the model is disabled or its provider is gone. `provider` names the provider and its kind; keys never appear. ```ts title="Signature" iam.api.inference.listModels( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listProviders [#listproviders] Lists the providers this tenant's models may use: its own, then its ancestors'. **HTTP:** `POST /api/iam/inference/listProviders` (requires a credential) · **Browser client:** `client.inference.listProviders()` * **Permission:** `iam:inference:read` on the tenant. * **Audited as:** `iam:inference:read`. The tenant's own providers come first (`inherited: false`), each group sorted by name. Each shows `kind`, `baseUrl`, `keyHint` (the key's last four characters), and `keyRotatedAt`, when the key was last replaced. ```ts title="Signature" iam.api.inference.listProviders( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## myUsage [#myusage] Returns the caller's own usage by model and the standing of every budget that covers them. **HTTP:** `POST /api/iam/inference/myUsage` (requires a credential) · **Browser client:** `client.inference.myUsage()` * **Permission:** None: any credential, for itself, in its own tenant. * **Audited as:** Not audited; it only reads. * **Errors:** `ACCESS_DENIED` when `tenantId` is not the credential's own tenant; `INVALID_INPUT` when `from` is after `to`. The report covers `from` (by default the start of this month, UTC) to `to` (by default now). `budgets` lists the standing of every budget that covers the caller for a model they used in that range or can see, so people can check what is left of an allowance before they run out. ```ts title="Signature" iam.api.inference.myUsage( credential: CredentialInput, input: { tenantId: string; from?: number; to?: number }, ): Promise ``` ## record [#record] Meters a call an external gateway made after `check`, by redeeming the check's ticket. **HTTP:** `POST /api/iam/inference/record` (requires a credential) · **Browser client:** `client.inference.record()` * **Permission:** `iam:inference:record` on the tenant, typically held by the gateway's service account. * **Audited as:** `iam:inference:record`; crossing a budget's `alertAtPercent` also as `inference:budget-alert`. * **Errors:** `INVALID_TICKET` (400) for an unknown, used, expired, or other-tenant ticket; `INVALID_INPUT` for a token count outside 0 to 100 000 000. The ticket supplies the caller as the check saw them (their identity, and the agent and delegation behind a delegated session) and the model; the gateway reports the token counts from the provider's response (`inputTokens`, `outputTokens`, and optionally `cacheReadTokens` and `cacheWriteTokens`), plus `status` (`error` for a failed call), `requestId`, and `latencyMs`. The usage is attributed to that caller and added to every covering budget and delegation spending cap, even when the caller's session has ended since the check, so a short session cannot make an allowed call escape its budgets. Each ticket works once. The result is `{ recorded: true, costMicros }`. ```ts await iam.api.inference.record(gatewayKey, { tenantId, ticket: result.ticket, inputTokens: response.usage.input_tokens, outputTokens: response.usage.output_tokens, }); ``` ```ts title="Signature" iam.api.inference.record( credential: CredentialInput, input: InferenceTokenUsage & { tenantId: string; ticket: string; status?: 'ok' | 'error'; requestId?: string; latencyMs?: number; }, ): Promise<{ recorded: true; costMicros: number }> ``` ## setBudget [#setbudget] Creates a budget, or replaces one when `budgetId` is given. **HTTP:** `POST /api/iam/inference/setBudget` (requires a credential) · **Browser client:** `client.inference.setBudget()` * **Permission:** `iam:inference:manage` on the tenant. * **Audited as:** `iam:inference:manage`. * **Errors:** `CONFLICT` (409) for a name another budget of the tenant uses (ignoring case); `NOT_FOUND` for a group or identity that is not in this tenant (or a deleted identity), or a `budgetId` that is not; `INVALID_INPUT` without any of `maxTokens`, `maxCostUsd` and `maxRequests`, for a `maxCostUsd` that is not above 0 or is above 10 000, a `maxTokens` or `maxRequests` below 1, an `alertAtPercent` outside 1 to 100, or a malformed model pattern. `subjectId` names the group or identity; a `tenant` budget covers the tenant itself. `maxRequests` caps the number of calls per window, a rate limit for agents that loop. With `budgetId`, fields you leave out keep their stored values, and `null` clears `maxTokens`, `maxCostUsd`, `maxRequests`, `models`, or `alertAtPercent`. `alertAtPercent` audits `inference:budget-alert` once per window when usage crosses that share of any limit. ```ts // Every person gets a million tokens a day. await iam.api.inference.setBudget(credential, { tenantId, name: 'Daily per person', subjectType: 'tenant', scope: 'each', period: 'day', maxTokens: 1_000_000, alertAtPercent: 80, }); // One agent, across its own key and every delegated session. await iam.api.inference.setBudget(credential, { tenantId, name: 'Triage agent', subjectType: 'identity', subjectId: agentId, period: 'month', maxCostUsd: 200, }); ``` ```ts title="Signature" iam.api.inference.setBudget( credential: CredentialInput, input: InferenceBudgetInput & { tenantId: string; budgetId?: string }, ): Promise ``` ## updateModel [#updatemodel] Changes one of this tenant's models, found by its name. **HTTP:** `POST /api/iam/inference/updateModel` (requires a credential) · **Browser client:** `client.inference.updateModel()` * **Permission:** `iam:inference:manage` on the tenant. * **Audited as:** `iam:inference:manage`. * **Errors:** `NOT_FOUND` when this tenant defines no model of that name or the new provider is not visible to it; `INVALID_INPUT` as for `createModel`. Fields you leave out keep their values, and `null` clears an optional one. `enabled: false` stops every call to the model at once (checks answer `MODEL_DISABLED`); new prices apply to calls metered from then on. The name cannot change. An inherited model changes only in the tenant that defines it; a sub-tenant publishes its own model of the same name instead. ```ts title="Signature" iam.api.inference.updateModel( credential: CredentialInput, input: ModelInput & { tenantId: string; name: string }, ): Promise ``` ## updateProvider [#updateprovider] Renames a provider, moves its base URL, or replaces its API key. **HTTP:** `POST /api/iam/inference/updateProvider` (requires a credential) · **Browser client:** `client.inference.updateProvider()` * **Permission:** `iam:inference:manage` on the provider, and a recent sign-in. * **Audited as:** `iam:inference:manage`. * **Errors:** `NOT_FOUND` when the provider is not this tenant's own; `CONFLICT` (409) for a name another provider uses; `ACCESS_DENIED` and `INVALID_INPUT` for `baseUrl` and `apiKey` as in `createProvider`; `RECENT_AUTH_REQUIRED` without a recent sign-in. A new `apiKey` is sealed like the first one and updates `keyHint` and `keyRotatedAt`; the next call through the gateway uses it. ```ts title="Signature" iam.api.inference.updateProvider( credential: CredentialInput, input: { tenantId: string; providerId: string; name?: string; apiKey?: string; baseUrl?: string; }, ): Promise ``` ## usage [#usage] Reports model calls between two times, grouped by identity, agent, model, or day. **HTTP:** `POST /api/iam/inference/usage` (requires a credential) · **Browser client:** `client.inference.usage()` * **Permission:** `iam:inference:read` on the tenant. * **Audited as:** `iam:inference:read`. * **Errors:** `INVALID_INPUT` for an unknown `groupBy` or a `from` after `to`. `from` defaults to the start of this month (UTC) and `to` to now; `identityId`, `agentId`, and `model` narrow the records, and `groupBy` defaults to `model`. Each row counts requests, errors, input, output, and cache tokens, and cost (`costMicros` and `costUsd`), largest cost first, with a `label` (email or name) when grouped by identity or agent; `totals` adds them up. A call an agent made for a person counts under the person by identity and under the agent by agent. ```ts const report = await iam.api.inference.usage(credential, { tenantId, groupBy: 'agent' }); ``` ```ts title="Signature" iam.api.inference.usage( credential: CredentialInput, input: { tenantId: string; from?: number; to?: number; identityId?: string; agentId?: string; model?: string; groupBy?: UsageReport['groupBy']; }, ): Promise ``` # invariants (/docs/reference/api/invariants) > Access invariants are guardrails: statements about who must never, or must always, be able to perform an action on a resource. Access invariants are guardrails: statements about who must never, or must always, be able to perform an action on a resource. "Contractors can never approve payments" and "the on-call group can always restart production" should hold whatever roles, policies, and groups say, but nobody re-checks every such rule by hand after each change. An invariant writes the rule down once and Better IAM checks it: on demand, on a schedule, and, in `enforce` mode, around every change to access, refusing any change that would break it. The guide is [change safety](/docs/guides/governance/change-safety). ## How an invariant is evaluated [#how-an-invariant-is-evaluated] An invariant names a `subject`, an `action`, a `resource`, and what to `expect`: * `subject` is exactly one of `{ identityId }`, `{ groupId }` (its live members), `{ attribute: { name, value } }` (identities whose declared attribute equals the value), or `{ everyone: true }`. Only active identities whose scheduled deactivation has not passed are evaluated. * `expect: 'deny'` means nobody in the subject may be allowed; `expect: 'allow'` means everyone in it must be. * Each person is evaluated like [`policies.simulate`](/docs/reference/api/policies#simulate), with the ordinary evaluator, so conditions, boundaries, ceilings, relationships, and activations count. `assumeMfa` (default `true`) evaluates them as MFA-verified, the most they can reach. A person the evaluation disagrees with is a violation, reported with the decision reason. Reports stop at 500 people per invariant (`truncated: true`); enforcement evaluates everyone. An invariant whose identity, group, or resource no longer exists reports an `error` instead of a result. ## Monitor and enforce [#monitor-and-enforce] `monitor` (the default) only reports. `enforce` also guards changes: the tenant's enforced invariants are evaluated before and after every operation that can change access, including role and policy edits and deletions, binding changes, just-in-time activation and approval, group membership changes, identity changes, package assignment, configuration apply, relationship and resource changes, authority revocation, and role assumption. When the operation newly breaks an invariant, or leaves it impossible to evaluate (for example by deleting the group it names), it fails with `INVARIANT_VIOLATION` (409) and its transaction rolls back. Violations that already existed do not block unrelated work, so you can switch an invariant to `enforce` while it is still broken. Changes made outside the operation envelope, such as scheduled jobs, inbound SCIM provisioning, and members accepting agreements, are not guarded. Schedule [`iam.checkInvariants`](/docs/reference/api#checkinvariants) (CLI `monitor-invariants`) to catch those: it stores each invariant's `lastCheck` and records `invariant:broken` and `invariant:restored` audit events once per change, which a webhook subscribed to `invariant:*` can route. | Method | What it does | Access | | ------------------- | -------------------------------------------------------------------------------------------- | ---------- | | [`create`](#create) | Stores an invariant and returns it with its current result. | Credential | | [`delete`](#delete) | Deletes an invariant, ending its monitoring and enforcement. | Credential | | [`list`](#list) | Lists the tenant's invariants by name, with the outcome of the last scheduled check. | Credential | | [`run`](#run) | Evaluates every invariant, or one, against the current configuration and reports which pass. | Credential | | [`update`](#update) | Changes any field of an invariant and returns it with its new result. | Credential | ## create [#create] Stores an invariant and returns it with its current result. **HTTP:** `POST /api/iam/invariants/create` (requires a credential) · **Browser client:** `client.invariants.create()` * **Permission:** `iam:invariants:manage` on the tenant. * **Audited as:** `iam:invariants:manage`. * **Errors:** `INVALID_INPUT` for a missing or overlong name (100 characters), a `subject` that is not exactly one of the four shapes, an undeclared identity attribute or a value of the wrong type, or an invalid `expect`, `mode`, or `assumeMfa`; `INVALID_ACTION` when the action is not in the catalog; `NOT_FOUND` when the named identity, group, or resource does not exist; `RESOURCE_RESOLVER_REQUIRED` for an application-owned resource type without a resolver; `CONFLICT` when an invariant with the same name (ignoring case) exists; `LIMIT_EXCEEDED` when the tenant already has 100 invariants. The resource must resolve now, because an invariant over a resource that does not exist could never be evaluated. The returned `result` shows at once whether the invariant holds; creating one in `enforce` mode succeeds even when it is already broken. ```ts const { invariant, result } = await iam.api.invariants.create(credential, { tenantId, name: 'Contractors never approve payments', subject: { attribute: { name: 'contractor', value: true } }, action: 'payments:approve', resource: { type: 'ledger', id: 'main' }, expect: 'deny', mode: 'enforce', }); // result.passed, result.violations: [{ identity: { id, name }, reason }] ``` ```ts title="Signature" iam.api.invariants.create( credential: CredentialInput, input: InvariantInput & { tenantId: string }, ): Promise<{ invariant: AccessInvariant; result: InvariantResult }> ``` ## delete [#delete] Deletes an invariant, ending its monitoring and enforcement. **HTTP:** `POST /api/iam/invariants/delete` (requires a credential) · **Browser client:** `client.invariants.delete()` * **Permission:** `iam:invariants:manage` on the invariant (`iam/{invariantId}`). * **Audited as:** `iam:invariants:manage`. * **Errors:** `NOT_FOUND` when the invariant is not in this tenant. An enforced invariant blocks deleting the group or resource it names; delete or change the invariant first. ```ts title="Signature" iam.api.invariants.delete( credential: CredentialInput, input: { tenantId: string; invariantId: string }, ): Promise<{ deleted: boolean }> ``` ## list [#list] Lists the tenant's invariants by name, with the outcome of the last scheduled check. **HTTP:** `POST /api/iam/invariants/list` (requires a credential) · **Browser client:** `client.invariants.list()` * **Permission:** `iam:invariants:read` on the tenant. * **Audited as:** `iam:invariants:read`. Each invariant carries `lastCheck` (`at`, `passed`, the violating identity ids, and an `error` message) once `iam.checkInvariants` has run. For a fresh evaluation, call `run`. ```ts title="Signature" iam.api.invariants.list( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## run [#run] Evaluates every invariant, or one, against the current configuration and reports which pass. **HTTP:** `POST /api/iam/invariants/run` (requires a credential) · **Browser client:** `client.invariants.run()` * **Permission:** `iam:invariants:read` on the tenant, or on the invariant (`iam/{invariantId}`) when you pass `invariantId`. * **Audited as:** `iam:invariants:read`. * **Errors:** `NOT_FOUND` when `invariantId` is not in this tenant. The result has `generatedAt`, a `summary` (`passed`, `failed`, and `errors` counts), and one entry per invariant with `passed`, `evaluated`, `truncated`, the `violations`, and an `error` when it could not be evaluated. It changes nothing: it does not update `lastCheck` or record audit events for broken invariants. In CI, run [`check-invariants`](/docs/reference/cli#check-invariants) with `--fail-on-broken` after applying configuration. ```ts title="Signature" iam.api.invariants.run( credential: CredentialInput, input: { tenantId: string; invariantId?: string }, ): Promise ``` ## update [#update] Changes any field of an invariant and returns it with its new result. **HTTP:** `POST /api/iam/invariants/update` (requires a credential) · **Browser client:** `client.invariants.update()` * **Permission:** `iam:invariants:manage` on the invariant (`iam/{invariantId}`). * **Audited as:** `iam:invariants:manage`. * **Errors:** `NOT_FOUND` when the invariant is not in this tenant; otherwise the same as `create`. Fields you leave out keep their values; `description: ''` removes the description. Use it to switch between `monitor` and `enforce`, or to point an invariant at a new group or resource before deleting the old one. ```ts title="Signature" iam.api.invariants.update( credential: CredentialInput, input: Partial & { tenantId: string; invariantId: string }, ): Promise<{ invariant: AccessInvariant; result: InvariantResult }> ``` # links (/docs/reference/api/links) > Account links connect a person's separate accounts in different tenants so your app can offer an account switcher. Account links connect a person's separate accounts in different tenants so your app can offer an account switcher. Identities belong to exactly one tenant, so a consultant working for two clients, or a founder with a personal workspace, has several accounts with their own passwords, factors, and roles. A link records that the same person controls both accounts; it never merges them. See [account linking](/docs/guides/concepts/tenants-and-identities#account-linking). ## What a link does and does not do [#what-a-link-does-and-does-not-do] A link supplies no permission. Each account keeps its own credentials, MFA, and roles, and holding a session for one account never opens the other: switching always needs a freshly authenticated credential for the target account, so its MFA and sign-in rules still apply. The link is what lets a UI list the other accounts and makes the switch an audited, deliberate step. Linking is opt-in. It works only when the deployment sets `onboarding: { mode: 'linked' }`; otherwise [`create`](#create) fails with `LINKING_DISABLED`. Only ordinary user sessions of two different tenants can link. Root administrators never can, because root authority must not be reachable from another account. These methods act on the caller's own accounts, so they need no `iam:*` permission. Anyone may call them for themselves. | Method | What it does | Access | | ------------------- | ------------------------------------------------------------------------------------------------------- | ---------- | | [`create`](#create) | Links the caller's account to another account of theirs in a different tenant, proving control of both. | Credential | | [`list`](#list) | Lists the accounts linked to the caller's account, for an account-switcher menu. | Credential | | [`revoke`](#revoke) | Removes a link the person no longer wants. | Credential | | [`switch`](#switch) | Opens a fresh session for a linked account, using a recently authenticated credential for that account. | Credential | ## create [#create] Links the caller's account to another account of theirs in a different tenant, proving control of both. **HTTP:** `POST /api/iam/links/create` (requires a credential) · **Browser client:** `client.links.create()` * **Permission:** The caller's own session, plus a credential for the target account. Both must be recently authenticated user sessions. * **Audited as:** `identity:link`, in the caller's tenant. * **Errors:** `LINKING_DISABLED` when the deployment does not enable linked onboarding; `RECENT_AUTH_REQUIRED` when either session is not recently authenticated or is a temporary credential; `IMPERSONATION_RESTRICTED` from a "view as" session; `INVALID_LINK` when either account is a root administrator, either credential is not a user session, or both accounts are in the same tenant; `CONFLICT` when the accounts are already linked. Recent authentication (by default within the last five minutes) on both sides is the proof: the person has just signed in to each account. Re-linking two accounts whose earlier link was revoked restores that link. ```ts // The person has just signed in to their other account, for example in a second sign-in form. const link = await iam.api.links.create(credential, { targetCredential: { token: otherAccountToken }, }); ``` ```ts title="Signature" iam.api.links.create( credential: CredentialInput, input: { targetCredential: CredentialInput }, ): Promise ``` ## list [#list] Lists the accounts linked to the caller's account, for an account-switcher menu. **HTTP:** `POST /api/iam/links/list` (requires a credential) · **Browser client:** `client.links.list()` * **Permission:** The caller's own session. Each entry has the link `id` and the other account's identity (`identityId`, `name`, `email`, `status`) and tenant (`tenantId`, `tenantName`, `tenantSlug`, `tenantStatus`). Show the statuses so people understand why a disabled account or a suspended organization cannot be opened. Revoked links are left out. The call is not audited. ```ts title="Signature" iam.api.links.list( credential: CredentialInput, ): Promise ``` ## revoke [#revoke] Removes a link the person no longer wants. **HTTP:** `POST /api/iam/links/revoke` (requires a credential) · **Browser client:** `client.links.revoke()` * **Permission:** The caller's own session, recently authenticated, as either side of the link. * **Audited as:** `identity:unlink`. * **Errors:** `NOT_FOUND` when the link does not exist or the caller is not one of its two accounts; `RECENT_AUTH_REQUIRED` without recent authentication. Either account may revoke the link. The accounts themselves are unaffected, and switching between them stops working at once. ```ts title="Signature" iam.api.links.revoke( credential: CredentialInput, input: { linkId: string }, ): Promise<{ revoked: boolean }> ``` ## switch [#switch] Opens a fresh session for a linked account, using a recently authenticated credential for that account. **HTTP:** `POST /api/iam/links/switch` (requires a credential) · **Browser client:** `client.links.switch()` * **Permission:** The caller's own user session, plus a recently authenticated user-session credential for the target account; both accounts must be the two sides of the link. * **Audited as:** `identity:switch`, in the target account's tenant. * **Errors:** `RECENT_AUTH_REQUIRED` when the target credential is not recently authenticated; `INVALID_LINK` when the link is missing or revoked, does not join these two accounts, or either side is a root administrator or not a user session. The new session carries the target credential's MFA state and authentication time, so the target tenant's rules still apply. Over HTTP the response sets the browser's session cookie, which moves the browser to the target account; the caller's original session is not ended. ```ts const { token, session } = await iam.api.links.switch(credential, { linkId, targetCredential: { token: freshTargetToken }, }); ``` ```ts title="Signature" iam.api.links.switch( credential: CredentialInput, input: { linkId: string; targetCredential: CredentialInput }, ): Promise ``` # oidcProviders (/docs/reference/api/oidc-providers) > OIDC providers are the external token issuers a tenant trusts for web-identity federation: GitHub Actions, GitLab, a Kubernetes cluster, or a cloud workload id… OIDC providers are the external token issuers a tenant trusts for web-identity federation: GitHub Actions, GitLab, a Kubernetes cluster, or a cloud workload identity service. Registering a provider lets [web-identity trusts](/docs/reference/api/trust#create) admit its tokens, so a CI job or workload can obtain a role session through [`sts.assumeRoleWithWebIdentity`](/docs/reference/api/sts#assumerolewithwebidentity) without storing any IAM secret. ## How providers are managed [#how-providers-are-managed] Providers are tenant-managed. Creating or changing one needs recent authentication and the deployment switch `sts.webIdentity.enabled` (see [web-identity federation](/docs/operations/deployment/configuration#web-identity-federation)); reading, deleting, disabling, and revoking sessions keep working when the switch is off, so you can always shut federation down. Each provider records the grant authority of the administrator who created it. That authority bounds every session admitted through the provider, and only its holder (or root) may edit or delete the provider, so an administrator who can change a provider's keys can never mint sessions beyond their own reach. A provider names its exact `issuer` (https, no query or fragment; never IAM's own issuer, and on `sts.webIdentity.allowedIssuers` when the deployment pins issuers), the `audiences` its tokens must carry, and where its keys come from: static public `jwks`, a `jwksUri`, or neither, in which case IAM uses OpenID Connect discovery on the issuer. Nothing is fetched when a provider is created. Fetches refuse private addresses, redirects, oversized and non-JSON responses, and keys are cached per provider for `sts.webIdentity.jwksCacheSeconds`. `replayProtection` is `'single-use'` by default: each token is redeemed at most once at this provider. Set it to `'off'` for tokens that SDKs reuse until they rotate, such as Kubernetes projected service account tokens. | Method | What it does | Access | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------- | | [`create`](#create) | Registers an OIDC provider whose tokens the tenant's web-identity trusts can admit. | Credential | | [`delete`](#delete) | Deletes a provider that no live trust uses any more. | Credential | | [`get`](#get) | Returns one provider. | Credential | | [`list`](#list) | Lists the tenant's OIDC providers, oldest first. | Credential | | [`revokeSessions`](#revokesessions) | Ends the web-identity sessions issued through a provider before a point in time, across every trust that uses it. | Credential | | [`update`](#update) | Changes a provider's name, audiences, keys, algorithms, token limits, replay protection, or enabled state. | Credential | ## create [#create] Registers an OIDC provider whose tokens the tenant's web-identity trusts can admit. **HTTP:** `POST /api/iam/oidcProviders/create` (requires a credential) · **Browser client:** `client.oidcProviders.create()` * **Permission:** `iam:oidc-providers:create` on the tenant, with recent authentication and an active grant authority. * **Audited as:** `iam:oidc-providers:create`. * **Errors:** `FEATURE_DISABLED` when web identity is not enabled; `CONFLICT` when the tenant already has a provider for this issuer; `INVALID_INPUT` for an issuer that is not https, is IAM's own, or is not on `sts.webIdentity.allowedIssuers`, a `jwksUri` that is not https on port 443 or points at a private address, private or weak static keys, both `jwks` and `jwksUri`, or an algorithm outside RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, and EdDSA; `RECENT_AUTH_REQUIRED`; `GRANT_AUTHORITY_REQUIRED`. Defaults: algorithms `RS256` and `ES256`, tokens at most 3600 seconds old and long (`maxTokenLifetimeSeconds`), 30 seconds of clock tolerance, single-use replay protection, and enabled. ```ts const github = await iam.api.oidcProviders.create(credential, { tenantId, name: 'GitHub Actions', issuer: 'https://token.actions.githubusercontent.com', audiences: ['https://iam.example.com'], }); ``` ```ts title="Signature" iam.api.oidcProviders.create( credential: CredentialInput, input: OidcProviderCreateInput, ): Promise ``` ## delete [#delete] Deletes a provider that no live trust uses any more. **HTTP:** `POST /api/iam/oidcProviders/delete` (requires a credential) · **Browser client:** `client.oidcProviders.delete()` * **Permission:** `iam:oidc-providers:delete` on the provider, with recent authentication, and the grant authority it was created under (or root). * **Audited as:** `iam:oidc-providers:delete`. * **Errors:** `CONFLICT` (409) while an unrevoked trust still names the provider; `ACCESS_DENIED` when another administrator's authority created it; `NOT_FOUND`; `RECENT_AUTH_REQUIRED`. Revoke the trusts that use it first; revoking a trust already ends its sessions. ```ts title="Signature" iam.api.oidcProviders.delete( credential: CredentialInput, input: { tenantId: string; providerId: string }, ): Promise<{ deleted: true }> ``` ## get [#get] Returns one provider. **HTTP:** `POST /api/iam/oidcProviders/get` (requires a credential) · **Browser client:** `client.oidcProviders.get()` * **Permission:** `iam:oidc-providers:read` on the provider. * **Audited as:** `iam:oidc-providers:read`. * **Errors:** `NOT_FOUND` when the provider is not in this tenant. ```ts title="Signature" iam.api.oidcProviders.get( credential: CredentialInput, input: { tenantId: string; providerId: string }, ): Promise ``` ## list [#list] Lists the tenant's OIDC providers, oldest first. **HTTP:** `POST /api/iam/oidcProviders/list` (requires a credential) · **Browser client:** `client.oidcProviders.list()` * **Permission:** `iam:oidc-providers:read` on the tenant. * **Audited as:** `iam:oidc-providers:read`. ```ts title="Signature" iam.api.oidcProviders.list( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## revokeSessions [#revokesessions] Ends the web-identity sessions issued through a provider before a point in time, across every trust that uses it. **HTTP:** `POST /api/iam/oidcProviders/revokeSessions` (requires a credential) · **Browser client:** `client.oidcProviders.revokeSessions()` * **Permission:** `iam:roles:revoke-sessions` on the provider, with recent authentication. * **Audited as:** `iam:roles:revoke-sessions` and `role:sessions-revoked` (with the watermark and the number of sessions deleted). * **Errors:** `INVALID_INPUT` when `before` is not a whole number of milliseconds, is negative, or lies in the future; `NOT_FOUND`; `RECENT_AUTH_REQUIRED`. `before` defaults to now, which ends every session issued so far. The provider's `sessionsRevokedBefore` watermark only moves forward, so a session issued earlier is refused at its next use even if it is created concurrently, and the matching rows are deleted at once. Use it to end sessions without changing the provider, for example after a workload's token leaked. After a provider's signing key leaks, removing the key with [`update`](#update) is enough: the update itself ends every session issued so far, so a separate revoke is not needed. Session JWTs checked offline by other services stay valid until they expire. ```ts const { revoked } = await iam.api.oidcProviders.revokeSessions(credential, { tenantId, providerId }); ``` ```ts title="Signature" iam.api.oidcProviders.revokeSessions( credential: CredentialInput, input: OidcProviderRevokeSessionsInput, ): Promise<{ providerId: string; sessionsRevokedBefore: number; revoked: number }> ``` ## update [#update] Changes a provider's name, audiences, keys, algorithms, token limits, replay protection, or enabled state. **HTTP:** `POST /api/iam/oidcProviders/update` (requires a credential) · **Browser client:** `client.oidcProviders.update()` * **Permission:** `iam:oidc-providers:update` on the provider, with recent authentication, and the grant authority it was created under (or root). * **Audited as:** `iam:oidc-providers:update`. * **Errors:** `FEATURE_DISABLED` when web identity is off, unless the update only sets `enabled: false`; `INVALID_INPUT` when the update changes nothing, tries to change the issuer, or gives invalid values (as for `create`); `ACCESS_DENIED` when another administrator's authority created it; `NOT_FOUND`; `RECENT_AUTH_REQUIRED`. Only the fields you pass change; `jwksUri: null` or `jwks: null` removes that key source. Cached keys are dropped, so the next token is checked against the new settings. Some changes end every session issued through the provider so far, across all of its trusts, because those sessions were admitted under the old rules: changing `jwks` or `jwksUri` (adding a key for a rotation counts), `algorithms`, `audiences`, `maxTokenLifetimeSeconds` or `clockToleranceSeconds`, and disabling the provider. Such an update moves the provider's `sessionsRevokedBefore` watermark to now (the response carries it) and deletes the matching session rows, as [`revokeSessions`](#revokesessions) would; workloads simply exchange a fresh token. Changing `name` or `replayProtection`, or enabling the provider, keeps live sessions. Plan key rotations for a quiet moment, or publish keys through `jwksUri` or discovery so rotations need no update at all. `enabled: false` is the kill switch: a disabled provider admits no exchanges, and the sessions issued through it end for good, so enabling it again does not bring them back. ```ts title="Signature" iam.api.oidcProviders.update( credential: CredentialInput, input: OidcProviderUpdateInput, ): Promise ``` # onboarding (/docs/reference/api/onboarding) > Onboarding flows are checklists for newcomers, customized at every level of the tenant tree. Onboarding flows are checklists for newcomers, customized at every level of the tenant tree. `member` flows walk people who join a tenant through their first steps (forms, acknowledgements, tasks, accepting terms of use, verifying their email address, enrolling MFA or a passkey); `tenant` flows are setup checklists for the organizations or projects below the defining tenant, with checks that follow the tenant's real state (a verified domain, enough owners and members, an MFA policy, SSO, directory sync). Flows defined at the platform root reach every tenant below; organizations and projects add their own, switch off unlocked inherited flows, and override the welcome screen. The repository guide is `docs/onboarding.md`. ## Levels and inheritance [#levels-and-inheritance] A flow's `appliesTo` decides whom it reaches: `tenant` (the defining tenant's own people), `descendants` (the people of the tenants below it) or `subtree` (both); `tenantTypes` narrows descendants to some tenant types. Tenant flows always reach descendants. A person sees the flows of every level at once, platform first. A tenant switches off an inherited member flow for itself and everything below it with `setSettings({ disabledFlowIds })`, unless the flow is `locked`. Welcome values (`welcomeTitle`, `welcomeMessage`, `supportEmail`, `supportUrl`) come from the nearest level that set them. Flows ask only people (or tenants) created after they took effect, unless `includeExisting` is set; member flows can also target people with a `rule` in the access-package rule language. Policies see `principal.onboarding` (completed flow names) and `principal.pendingOnboarding` (required flows still open), read only when a condition names them: ```json { "effect": "deny", "actions": ["documents:*"], "resources": ["*"], "conditions": { "NumericGreaterThan": { "principal.pendingOnboarding": 0 } } } ``` | Method | What it does | Access | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`createFlow`](#createflow) | Creates a flow at this tenant's level. | Credential | | [`deleteFlow`](#deleteflow) | Deletes a flow with every progress record of it, in every tenant it reached, and removes it from tenants' `disabledFlowIds`. | Credential | | [`effective`](#effective) | Everything onboarding looks like from this tenant, for administration pages. | Credential | | [`getFlow`](#getflow) | Returns one flow this tenant defines. | Credential | | [`listFlows`](#listflows) | Lists the flows this tenant defines, oldest first, optionally for one `audience`. | Credential | | [`memberProgress`](#memberprogress) | One person's member flows in this tenant, with the state of every step and their answers, and `pending` (required flows still open). | Credential | | [`mine`](#mine) | The caller's own onboarding: the welcome screen and their member flows, with step states and their own answers. | Credential | | [`progress`](#progress) | Progress through one flow as this tenant sees it. | Credential | | [`resetProgress`](#resetprogress) | Clears progress through a flow, or one step of it (`stepId`), so people or tenants go through it again. | Credential | | [`setSettings`](#setsettings) | Replaces this tenant's onboarding settings: `welcomeTitle`, `welcomeMessage`, `supportEmail`, `supportUrl` (empty or `null` falls back to the level above), and `disabledFlowIds`, the inherited member flows switched off for this tenant and every tenant below it. | Credential | | [`setup`](#setup) | This tenant's own setup checklists (the tenant flows defined above it), with the state of every step and the answers. | Credential | | [`submitSetupStep`](#submitsetupstep) | Completes a `form`, `acknowledge`, or `task` step of this tenant's setup. | Credential | | [`submitStep`](#submitstep) | Completes one step of the caller's own member flow: `answers` for a form, `acknowledged: true` for an acknowledgement, nothing for a task (an `admin`-verified task is submitted for review). | Credential | | [`updateFlow`](#updateflow) | Changes any field of a flow but its audience. Changing the steps bumps `version`: finished steps stay finished (progress is kept per step ID) and a new required step reopens the flow for everyone it applies to. | Credential | | [`verifyStep`](#verifystep) | Approves (the default) or sends back (`approve: false`, with an optional `note`) an administrator-verified task. | Credential | ## createFlow [#createflow] Creates a flow at this tenant's level. **HTTP:** `POST /api/iam/onboarding/createFlow` (requires a credential) · **Browser client:** `client.onboarding.createFlow()` * **Permission:** `iam:onboarding:manage` on the tenant. Form fields that fill identity attributes (`attribute`) also need `iam:identities:update`; `completionGroupIds` need `iam:groups:update` on each group and the use of the grant authorities behind the group's bindings. Both are refused from role sessions, session tokens, and impersonation. * **Audited as:** `iam:onboarding:manage`. * **Errors:** `CONFLICT` (409) for a name the tenant already uses; `LIMIT_EXCEEDED` (409) past 50 flows; `INVALID_INPUT` for an unknown field, a step kind the audience does not allow, duplicate step IDs, an undeclared or mistyped attribute, a textarea field mapped to an attribute, attribute mappings on a flow that reaches only tenants below, `tenantTypes` that cannot exist below the tenant, `descendants` on a tenant type without children, completion groups on a flow that reaches only descendants, or a rule that tests `identity.groups` on such a flow; `NOT_FOUND` for an unknown completion group; `ACCESS_DENIED` when the attribute or group permissions are missing. Steps (1-25) have a stable `id` (lowercase letters, digits, dashes) and a `kind`: `form` (1-20 `fields`), `acknowledge` (`content`), `task` (`url`, `verification: 'self' | 'admin'`), and for member flows `agreement` (by name), `verify-email`, `mfa`, `passkey`; for tenant flows `check` (`verified-domain`, `members`, `owners`, `mfa-policy`, `agreement`, `slug`, `sso`, `directory-sync`, `member-onboarding`, with `minimum` for members and owners). `appliesTo` defaults to `descendants` at the root and `tenant` elsewhere. ```ts await iam.api.onboarding.createFlow(rootCredential, { tenantId: rootTenantId, name: 'Platform essentials', audience: 'member', locked: true, steps: [ { id: 'conduct', kind: 'acknowledge', title: 'Acceptable use', content: 'Use the service lawfully.' }, { id: 'mfa', kind: 'mfa', title: 'Set up two-step verification' }, ], }); ``` ```ts title="Signature" iam.api.onboarding.createFlow( credential: CredentialInput, input: OnboardingFlowInput & { tenantId: string }, ): Promise ``` ## deleteFlow [#deleteflow] Deletes a flow with every progress record of it, in every tenant it reached, and removes it from tenants' `disabledFlowIds`. **HTTP:** `POST /api/iam/onboarding/deleteFlow` (requires a credential) · **Browser client:** `client.onboarding.deleteFlow()` * **Permission:** `iam:onboarding:manage` on the flow. * **Audited as:** `iam:onboarding:manage`. * **Errors:** `NOT_FOUND` when this tenant does not define the flow. Returns `{ deleted: true, progressRemoved }`. ```ts title="Signature" iam.api.onboarding.deleteFlow( credential: CredentialInput, input: { tenantId: string; flowId: string }, ): Promise<{ deleted: boolean; progressRemoved: number }> ``` ## effective [#effective] Everything onboarding looks like from this tenant, for administration pages. **HTTP:** `POST /api/iam/onboarding/effective` (requires a credential) · **Browser client:** `client.onboarding.effective()` * **Permission:** `iam:onboarding:read` on the tenant. * **Audited as:** `iam:onboarding:read`. Returns the `levels` (root first), `memberFlows` reaching the tenant's people (own and inherited, each with `source`, `inherited`, `disabledBy`, and `canDisable`), the `setupFlows` the tenant's administrators complete, its `ownFlows`, the `descendantTypes` a flow may target, the declared `identityAttributes`, and `settings` (`own` and `resolved`, with the level each value came from). Inherited flows omit the defining tenant's completion groups and author. ```ts title="Signature" iam.api.onboarding.effective( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## getFlow [#getflow] Returns one flow this tenant defines. **HTTP:** `POST /api/iam/onboarding/getFlow` (requires a credential) · **Browser client:** `client.onboarding.getFlow()` * **Permission:** `iam:onboarding:read` on the flow. * **Audited as:** `iam:onboarding:read`. * **Errors:** `NOT_FOUND` when this tenant does not define the flow. ```ts title="Signature" iam.api.onboarding.getFlow( credential: CredentialInput, input: { tenantId: string; flowId: string }, ): Promise ``` ## listFlows [#listflows] Lists the flows this tenant defines, oldest first, optionally for one `audience`. **HTTP:** `POST /api/iam/onboarding/listFlows` (requires a credential) · **Browser client:** `client.onboarding.listFlows()` * **Permission:** `iam:onboarding:read` on the tenant. * **Audited as:** `iam:onboarding:read`. ```ts title="Signature" iam.api.onboarding.listFlows( credential: CredentialInput, input: { tenantId: string; audience?: OnboardingAudience }, ): Promise ``` ## memberProgress [#memberprogress] One person's member flows in this tenant, with the state of every step and their answers, and `pending` (required flows still open). **HTTP:** `POST /api/iam/onboarding/memberProgress` (requires a credential) · **Browser client:** `client.onboarding.memberProgress()` * **Permission:** `iam:onboarding:read` on the identity. * **Audited as:** `iam:onboarding:read`. * **Errors:** `NOT_FOUND` for an identity outside the tenant or deleted. ```ts title="Signature" iam.api.onboarding.memberProgress( credential: CredentialInput, input: { tenantId: string; identityId: string }, ): Promise<{ identity: { id: string; name: string; email?: string }; flows: OnboardingFlowStatus[]; pending: number; }> ``` ## mine [#mine] The caller's own onboarding: the welcome screen and their member flows, with step states and their own answers. **HTTP:** `POST /api/iam/onboarding/mine` (requires a credential) · **Browser client:** `client.onboarding.mine()` * **Permission:** None beyond an ordinary session (or API key) of the tenant. * **Audited as:** `onboarding:complete` for each flow seen complete for the first time in its current version. * **Errors:** `ACCESS_DENIED` from a role session, a session token, or another tenant's session. Returns `{ tenant, welcome, flows, pending, complete }`. Each flow carries `source` (the level that defines it), `required`, `steps` (`state`: `pending`, `complete`, `submitted`, `rejected` with the reviewer's `note`, or `unavailable`), `done`, and `total`. Completion groups of newly finished flows are applied after the read, in their own transaction; a refusal (such as a separation-of-duties rule) is kept as the progress record's `completionError` and retried on the next read. Impersonating administrators see the checklist, but nothing is recorded. Service accounts have no flows. ```ts title="Signature" iam.api.onboarding.mine( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## progress [#progress] Progress through one flow as this tenant sees it. **HTTP:** `POST /api/iam/onboarding/progress` (requires a credential) · **Browser client:** `client.onboarding.progress()` * **Permission:** `iam:onboarding:read` on the flow. * **Audited as:** `iam:onboarding:read`. * **Errors:** `NOT_FOUND` when the flow neither belongs to nor reaches this tenant (setup flows report only at the tenant that defines them). For member flows, `members` lists this tenant's people the flow applies to, with `done`, `total`, `complete`, `awaiting` (tasks waiting for review), and `answers` by step. A flow defined here that reaches tenants below adds `descendants`: per tenant, how many people it applies to and how many finished, never names. For setup flows, `tenants` lists every descendant tenant the flow applies to, with its answers. `summary` counts subjects and completions; `truncated` is set past 1000 descendant tenants. ```ts title="Signature" iam.api.onboarding.progress( credential: CredentialInput, input: { tenantId: string; flowId: string }, ): Promise ``` ## resetProgress [#resetprogress] Clears progress through a flow, or one step of it (`stepId`), so people or tenants go through it again. **HTTP:** `POST /api/iam/onboarding/resetProgress` (requires a credential) · **Browser client:** `client.onboarding.resetProgress()` * **Permission:** `iam:onboarding:manage` on the flow. The tenant that defines the flow may reset anyone it reaches (everyone when `subjectId` is omitted); a tenant a member flow reaches may reset its own people. * **Audited as:** `onboarding:reset`, with `reset` (the number of progress records) and the `subjectId` / `stepId`. * **Errors:** `NOT_FOUND` for a flow that does not reach the tenant or an unknown step. ```ts title="Signature" iam.api.onboarding.resetProgress( credential: CredentialInput, input: { tenantId: string; flowId: string; subjectId?: string; stepId?: string }, ): Promise<{ reset: number }> ``` ## setSettings [#setsettings] Replaces this tenant's onboarding settings: `welcomeTitle`, `welcomeMessage`, `supportEmail`, `supportUrl` (empty or `null` falls back to the level above), and `disabledFlowIds`, the inherited member flows switched off for this tenant and every tenant below it. **HTTP:** `POST /api/iam/onboarding/setSettings` (requires a credential) · **Browser client:** `client.onboarding.setSettings()` * **Permission:** `iam:onboarding:manage` on the tenant. * **Audited as:** `iam:onboarding:manage`. * **Errors:** `INVALID_INPUT` for a flow that is not an inherited member flow of the tenant, a locked flow, a malformed email or URL, or an unknown field. A switch kept from before whose flow no longer reaches the tenant (deleted, retargeted, or locked since) is dropped quietly, so re-saving never fails on someone else's change. ```ts title="Signature" iam.api.onboarding.setSettings( credential: CredentialInput, input: OnboardingSettingsInput, ): Promise ``` ## setup [#setup] This tenant's own setup checklists (the tenant flows defined above it), with the state of every step and the answers. **HTTP:** `POST /api/iam/onboarding/setup` (requires a credential) · **Browser client:** `client.onboarding.setup()` * **Permission:** `iam:onboarding:read` on the tenant. * **Audited as:** `iam:onboarding:read`, and `onboarding:complete` for a checklist seen complete for the first time. Returns the same shape as `mine`. Check steps carry a `detail` such as `1 of 2 owners`. ```ts title="Signature" iam.api.onboarding.setup( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## submitSetupStep [#submitsetupstep] Completes a `form`, `acknowledge`, or `task` step of this tenant's setup. **HTTP:** `POST /api/iam/onboarding/submitSetupStep` (requires a credential) · **Browser client:** `client.onboarding.submitSetupStep()` * **Permission:** `iam:onboarding:manage` on the flow. * **Audited as:** `onboarding:step`, and `onboarding:complete` when the checklist is done. * **Errors:** `INVALID_INPUT` for a check step (they follow the tenant's state), missing or invalid answers, or an acknowledgement without `acknowledged: true`; `IMPERSONATION_RESTRICTED` while impersonating; `NOT_FOUND` for a flow that does not apply to the tenant or an unknown step. ```ts title="Signature" iam.api.onboarding.submitSetupStep( credential: CredentialInput, input: { tenantId: string; flowId: string; stepId: string; answers?: Record; acknowledged?: boolean; }, ): Promise<{ flow: OnboardingFlowStatus }> ``` ## submitStep [#submitstep] Completes one step of the caller's own member flow: `answers` for a form, `acknowledged: true` for an acknowledgement, nothing for a task (an `admin`-verified task is submitted for review). **HTTP:** `POST /api/iam/onboarding/submitStep` (requires a credential) · **Browser client:** `client.onboarding.submitStep()` * **Permission:** None beyond an ordinary session of the tenant. * **Audited as:** `onboarding:step` (with the attributes the answers filled), and `onboarding:complete` when the flow is done. * **Errors:** `INVALID_INPUT` for a step that completes on its own, a missing required answer, an answer of the wrong type or outside a select's choices, or an unknown answer key; `IMPERSONATION_RESTRICTED` while impersonating; `NOT_FOUND` for a flow that does not apply to the caller or an unknown step. Answers mapped to identity attributes fill only empty attributes, and only when the caller's own tenant defines the flow (an inherited flow records answers but never writes the tenant's identities); values an administrator or directory sync set are kept. Returns `{ flow, attributesFilled }`. ```ts await iam.api.onboarding.submitStep(credential, { tenantId, flowId, stepId: 'profile', answers: { department: 'Engineering' }, }); ``` ```ts title="Signature" iam.api.onboarding.submitStep( credential: CredentialInput, input: { tenantId: string; flowId: string; stepId: string; answers?: Record; acknowledged?: boolean; }, ): Promise<{ flow: OnboardingFlowStatus; attributesFilled: string[] }> ``` ## updateFlow [#updateflow] Changes any field of a flow but its audience. Changing the steps bumps `version`: finished steps stay finished (progress is kept per step ID) and a new required step reopens the flow for everyone it applies to. **HTTP:** `POST /api/iam/onboarding/updateFlow` (requires a credential) · **Browser client:** `client.onboarding.updateFlow()` * **Permission:** `iam:onboarding:manage` on the flow, plus the attribute and group permissions of `createFlow` for newly mapped attributes and newly added completion groups. * **Audited as:** `iam:onboarding:manage`. * **Errors:** as `createFlow`, and `NOT_FOUND` when this tenant does not define the flow. `null` clears `description`, `tenantTypes`, `rule`, and `completionGroupIds`. Pausing (`enabled: false`) asks nobody; the flow's `effectiveFrom` is set the first time it is enabled. ```ts title="Signature" iam.api.onboarding.updateFlow( credential: CredentialInput, input: OnboardingFlowUpdate, ): Promise ``` ## verifyStep [#verifystep] Approves (the default) or sends back (`approve: false`, with an optional `note`) an administrator-verified task. **HTTP:** `POST /api/iam/onboarding/verifyStep` (requires a credential) · **Browser client:** `client.onboarding.verifyStep()` * **Permission:** `iam:onboarding:manage`. For member flows the caller administers the person's tenant (`tenantId`) and `subjectId` is the person; for setup flows the caller administers the tenant that defines the flow and `subjectId` is the tenant being set up. * **Audited as:** `onboarding:verify` or `onboarding:reject`. * **Errors:** `INVALID_INPUT` for a step that is not an administrator-verified task, or when people verify their own onboarding; `IMPERSONATION_RESTRICTED` while impersonating; `NOT_FOUND` when the flow does not apply to the subject. Approving the last open step records the flow as complete; completion groups are applied the next time the person reads their onboarding. ```ts title="Signature" iam.api.onboarding.verifyStep( credential: CredentialInput, input: { tenantId: string; flowId: string; subjectId: string; stepId: string; approve?: boolean; note?: string; }, ): Promise<{ flow: OnboardingFlowStatus }> ``` # packages (/docs/reference/api/packages) > Access packages bundle roles and group memberships that are granted, requested, and removed together. Access packages bundle roles and group memberships that are granted, requested, and removed together. An onboarding kit, a project profile, or a vendor's access becomes one named package instead of a checklist of separate grants, and removing it takes away exactly what it gave. See the [access packages guide](/docs/guides/privileged-access/access-packages) for a walkthrough. ## How packages are granted [#how-packages-are-granted] A package can reach a person in three ways, and one package can use all of them: * **Assignment.** An administrator calls `assign`. The caller needs `iam:packages:assign` on the package plus everything the direct calls need: `iam:bindings:create` on each role and `iam:groups:update` on each group. The records are created under the caller's own [grant authority](/docs/guides/authorization/roles#grant-authorities), so a package never lets anyone grant more than they could grant by hand. * **Self-service request.** When the package is `requestable`, a member holding `iam:packages:request` on it asks for it with `request`, and an approver decides with `approveRequest` or `denyRequest`. Who may approve depends on the package: with neither `approverGroupId` nor `managerApproval`, anyone holding `iam:packages:approve` on the package; otherwise only live members of the approver group, the requester's manager (with `managerApproval`), or a root administrator. Nobody decides on their own request, and never from an [impersonation](/docs/guides/authentication/impersonation) session. Approval assigns the package under the approver's authority, so the approver needs the same rights as `assign`. A request waits for the tenant's `approvalLifetimeMs` (one day by default, set with [`tenants.setAccessPolicy`](/docs/reference/api/tenants#setaccesspolicy)), never beyond the end it asks for. * **Rule (birthright).** A package with an `autoAssign` rule is given to every active identity that matches the rule and taken back from automatic holders that stop matching. A reconciler applies the rule under the grant authority of the rule's owner (whoever last saved the rule or changed the package's contents). It runs after identity changes and rule saves, on demand with `reconcile`, and as the scheduled job [`iam.reconcilePackages()`](/docs/reference/api#reconcilepackages), which you must schedule. See [automatic assignment](/docs/guides/privileged-access/automatic-assignment) for the rule language, grace periods, and the safety brake. `maxDurationMs` makes an end date mandatory for assignments and requests and caps it; `requireJustification` makes the justification mandatory. A rule package cannot have `maxDurationMs` (use the rule's `graceMs` for a delayed end), and its automatic assignments satisfy `requireJustification` with "Automatic: matches the package rule". ## What an assignment owns [#what-an-assignment-owns] An assignment turns every packaged role into an identity binding of its own and every packaged group into a membership, all ending at the assignment's `expiresAt`. Each record is tagged with the assignment. Because the bindings are the assignment's own, a package never depends on, replaces, or removes a binding someone granted by hand. A group has one membership record per person, so memberships are shared: * A membership the person already holds for at least as long is left alone and reported in `skipped`. * A shorter membership is extended and becomes the assignment's. * When two of a person's packages include the same group, the membership belongs to whichever needs it longest and passes to the other when that one is revoked or shortened. Revoking removes exactly the records the assignment still owns, together with any just-in-time activations they carried, then the assignment itself. Editing one of its records by hand (`bindings.update`, `groups.updateMember`, re-adding a lapsed member) takes that record over, so revoking the package no longer removes it. An assignment ends by itself at `expiresAt`: it stops granting at once, and the purge worker (`iam.purgeDeleted()`) deletes it with its records later. An assignment whose bindings no longer grant, for example because the assigner was offboarded and their authority revoked, is reported as `broken`; assign or request the package again to replace it. Offboarding an identity revokes its assignments and cancels its pending requests; deleting it removes both. A role or group cannot be deleted while a package includes it. | Method | What it does | Access | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------- | | [`approveRequest`](#approverequest) | Grants a pending package request by assigning the package to the requester under your own authority. | Credential | | [`assign`](#assign) | Grants a package to a person: one binding per role and one membership per group, all ending together. | Credential | | [`cancelRequest`](#cancelrequest) | Withdraws one of your own pending package requests. | Credential | | [`create`](#create) | Defines a package of roles and groups, optionally requestable or assigned automatically by a rule. | Credential | | [`delete`](#delete) | Deletes a package nobody holds, together with its request history. | Credential | | [`denyRequest`](#denyrequest) | Refuses a pending package request, with an optional note to the requester. | Credential | | [`extend`](#extend) | Moves the end of a person's manual assignment, for the assignment and every record it created at once. | Credential | | [`get`](#get) | Returns one package with its live holder counts and, for a rule package, the rule's state. | Credential | | [`list`](#list) | Lists the tenant's packages by name, each with its live holder counts. | Credential | | [`listApprovals`](#listapprovals) | Lists the pending package requests you may decide on. | Credential | | [`listAssignments`](#listassignments) | Lists the holders of a package, or the packages of a person, newest first. | Credential | | [`listMine`](#listmine) | Returns your self-service view: the requestable packages with your status on each, your assignments, and your recent requests. | Credential | | [`listRequests`](#listrequests) | Lists package requests for a package or a person, newest first, optionally by status. | Credential | | [`previewAutoAssign`](#previewautoassign) | Shows what a package rule matches and, for a package, what a reconcile would change, without writing anything. | Credential | | [`reconcile`](#reconcile) | Runs the package-rule reconciler now for the tenant or one package, optionally confirming held-back changes. | Credential | | [`request`](#request) | Asks for a requestable package for yourself; approvers are notified and one of them decides. | Credential | | [`revoke`](#revoke) | Removes a package from a person, taking away exactly the bindings and memberships the assignment still owns. | Credential | | [`update`](#update) | Changes a package's name, description, contents, request settings, or rule. | Credential | ## approveRequest [#approverequest] Grants a pending package request by assigning the package to the requester under your own authority. **HTTP:** `POST /api/iam/packages/approveRequest` (requires a credential) · **Browser client:** `client.packages.approveRequest()` * **Permission:** `iam:packages:approve` on the package; when the package names approvers, membership of the approver group or being the requester's manager (or root); plus the rights `assign` needs for every role and group. * **Audited as:** `iam:packages:approve`, plus `package:request-approved` with the assignment's counts, skips, and end. * **Errors:** `INVALID_TRANSITION` when the request is no longer pending or the package is no longer requestable; `NOT_FOUND` when the request is not in this tenant; `INVALID_INPUT` for your own request or an end the package does not allow; `ACCESS_DENIED` when you are not a designated approver or cannot grant one of the packaged roles or groups; `IMPERSONATION_RESTRICTED` from an impersonation session; `GRANT_AUTHORITY_REQUIRED` without a grant authority; `SOD_CONFLICT` when the assignment would create a [separation-of-duties](/docs/guides/authorization/separation-of-duties) conflict; `INVARIANT_VIOLATION` when it would break an enforced invariant. The assignment ends at the `expiresAt` you pass or, without it, at the end the requester asked for, validated against the package's `maxDurationMs`. The optional `note` is stored on the request. When the deployment sends email, the requester receives a `package-decided` message. ```ts await iam.api.packages.approveRequest(credential, { tenantId, requestId, expiresAt: Date.now() + 14 * 24 * 60 * 60 * 1000, // shorter than the 30 days they asked for note: 'Two weeks covers the migration.', }); ``` ```ts title="Signature" iam.api.packages.approveRequest( credential: CredentialInput, input: { tenantId: string; requestId: string; expiresAt?: number; note?: string }, ): Promise ``` ## assign [#assign] Grants a package to a person: one binding per role and one membership per group, all ending together. **HTTP:** `POST /api/iam/packages/assign` (requires a credential) · **Browser client:** `client.packages.assign()` * **Permission:** `iam:packages:assign` on the package, `iam:bindings:create` on each packaged role, `iam:groups:update` on each packaged group, and authority over the role bindings of each group the person joins. * **Audited as:** `iam:packages:assign`, plus `package:assign` with the created counts, `skipped`, end, and justification. * **Errors:** `CONFLICT` when the person already holds a manual assignment of the package that is not broken; `NOT_FOUND` when the package or person is not in this tenant or the person is deleted; `INVALID_INPUT` when an end is required and missing, exceeds `maxDurationMs`, or a required justification is missing; `ACCESS_DENIED` without the rights for one of the parts; `GRANT_AUTHORITY_REQUIRED` without a grant authority; `SOD_CONFLICT` and `INVARIANT_VIOLATION` when the grant would create a conflict or break an enforced invariant. Everything happens in one transaction, so either the whole package is granted or nothing is. The result carries the assignment, `created` (bindings and memberships), `skipped` (memberships the person already held for as long), and `replacedAutomatic`. Assigning a package the person holds through its rule takes the automatic assignment over: it becomes a manual one under your authority. Assigning also marks the person's pending request for the package as approved. ```ts const result = await iam.api.packages.assign(credential, { tenantId, packageId, identityId, expiresAt: Date.now() + 90 * 24 * 60 * 60 * 1000, justification: 'Joins the Atlas project for Q4', }); // result.created: { bindings: 2, memberships: 1 }, result.skipped: [...] ``` ```ts title="Signature" iam.api.packages.assign( credential: CredentialInput, input: AssignmentInput, ): Promise<{ created: { bindings: number; memberships: number }; skipped: string[]; replacedAutomatic: boolean; id: string; tenantId: string; packageId: string; identityId: string; assignedBy: string; assignedAt: number; expiresAt?: number; justification?: string; bindingIds: string[]; membershipIds: string[]; packageName: string; identityName: string; identityEmail?: string; expired: boolean; broken: boolean; automatic: boolean; }> ``` ## cancelRequest [#cancelrequest] Withdraws one of your own pending package requests. **HTTP:** `POST /api/iam/packages/cancelRequest` (requires a credential) · **Browser client:** `client.packages.cancelRequest()` * **Permission:** `iam:packages:request` on the package, and you must be the requester. * **Audited as:** `iam:packages:request`, plus `package:request-cancelled`. * **Errors:** `ACCESS_DENIED` when the request is someone else's; `INVALID_TRANSITION` when it is no longer pending; `NOT_FOUND` when the request is not in this tenant. ```ts title="Signature" iam.api.packages.cancelRequest( credential: CredentialInput, input: { tenantId: string; requestId: string }, ): Promise ``` ## create [#create] Defines a package of roles and groups, optionally requestable or assigned automatically by a rule. **HTTP:** `POST /api/iam/packages/create` (requires a credential) · **Browser client:** `client.packages.create()` * **Permission:** `iam:packages:create` on the tenant. With `autoAssign`, also `iam:packages:assign` on the package and every right `assign` needs, including authority over the packaged groups' bindings. * **Audited as:** `iam:packages:create`, plus `package:auto-rule` when a rule is set. * **Errors:** `CONFLICT` when a package with the same name (ignoring case) exists; `INVALID_INPUT` when it has no role or group, more than 50 of either, a `maxDurationMs` outside one minute to ten years, or a rule that does not validate; `PROTECTED_RESOURCE` for a protected (owner) role; `NOT_FOUND` when a role, group, or approver group is not in this tenant; `INVALID_POLICY` when a rule clause is not a valid condition block. With a rule, also `IMPERSONATION_RESTRICTED`, `ACCESS_DENIED`, and `GRANT_AUTHORITY_REQUIRED` from the owner checks. A new package without a rule grants nothing until you assign it or someone requests it. With `autoAssign` you become the rule's owner, and the rule must be set from an ordinary session or API key, never an assumed role. After the save commits, a first reconcile of up to 200 changes runs and its result is returned as `reconcile`; if it fails, the call still succeeds and the scheduled job catches up. The save also pre-approves the rule's planned counts for a day, so scheduled runs in that window apply the rest without the safety brake holding them back. ```ts // Everyone in engineering (people, not service accounts) gets the kit automatically. const pkg = await iam.api.packages.create(credential, { tenantId, name: 'Engineering onboarding', roleIds: [developerRole.id], groupIds: [engineeringGroup.id], autoAssign: { include: [{ StringEquals: { 'principal.kind': 'user', 'principal.department': 'engineering' } }], graceMs: 7 * 24 * 60 * 60 * 1000, }, }); // pkg.reconcile?.assigned: how many people received it right away ``` ```ts title="Signature" iam.api.packages.create( credential: CredentialInput, input: PackageInput, ): Promise ``` ## delete [#delete] Deletes a package nobody holds, together with its request history. **HTTP:** `POST /api/iam/packages/delete` (requires a credential) · **Browser client:** `client.packages.delete()` * **Permission:** `iam:packages:delete` on the package. * **Audited as:** `iam:packages:delete`. * **Errors:** `RESOURCE_IN_USE` (409) while any live assignment exists; `NOT_FOUND` when the package is not in this tenant. Revoke manual assignments first. For a rule package, set `autoAssign` to `null` with `update` so reconciliation removes the automatic assignments, then delete. Ended assignments, requests, and rule issues are deleted with the package. ```ts title="Signature" iam.api.packages.delete( credential: CredentialInput, input: { tenantId: string; packageId: string }, ): Promise<{ deleted: true }> ``` ## denyRequest [#denyrequest] Refuses a pending package request, with an optional note to the requester. **HTTP:** `POST /api/iam/packages/denyRequest` (requires a credential) · **Browser client:** `client.packages.denyRequest()` * **Permission:** `iam:packages:approve` on the package and, when the package names approvers, membership of the approver group or being the requester's manager (or root). * **Audited as:** `iam:packages:approve`, plus `package:request-denied`. * **Errors:** `INVALID_TRANSITION` when the request is no longer pending; `NOT_FOUND` when the request is not in this tenant; `INVALID_INPUT` for your own request; `ACCESS_DENIED` when you are not a designated approver; `IMPERSONATION_RESTRICTED` from an impersonation session. Denying needs no grant rights, since nothing is granted. When the deployment sends email, the requester receives a `package-decided` message with your note. ```ts title="Signature" iam.api.packages.denyRequest( credential: CredentialInput, input: { tenantId: string; requestId: string; note?: string }, ): Promise ``` ## extend [#extend] Moves the end of a person's manual assignment, for the assignment and every record it created at once. **HTTP:** `POST /api/iam/packages/extend` (requires a credential) · **Browser client:** `client.packages.extend()` * **Permission:** `iam:packages:assign` on the package. Lengthening, or `expiresAt: null`, is granting: it also needs the rights `assign` needs and a grant authority. * **Audited as:** `iam:packages:assign`, plus `package:extend` with the previous and new end. * **Errors:** `NOT_FOUND` when the person holds no live assignment of the package; `INVALID_TRANSITION` for an automatic assignment; `INVALID_INPUT` when `expiresAt` is missing or the new end breaks the package's `maxDurationMs` (including `null` on a capped package); `ACCESS_DENIED` or `GRANT_AUTHORITY_REQUIRED` when lengthening without the rights. Shortening needs no more than revoking does. When you lengthen, the assignment's bindings move to your authority, so the longer grant is bounded by what you may give. Shortening a shared membership hands it to another of the person's packages that still needs it longer. Automatic assignments end when the person stops matching the rule; assign the package manually to take one over and set an end. ```ts await iam.api.packages.extend(credential, { tenantId, packageId, identityId, expiresAt: Date.parse('2026-12-31') }); ``` ```ts title="Signature" iam.api.packages.extend( credential: CredentialInput, input: { tenantId: string; packageId: string; identityId: string; expiresAt: number | null; }, ): Promise ``` ## get [#get] Returns one package with its live holder counts and, for a rule package, the rule's state. **HTTP:** `POST /api/iam/packages/get` (requires a credential) · **Browser client:** `client.packages.get()` * **Permission:** `iam:packages:read` on the package. * **Audited as:** `iam:packages:read`. * **Errors:** `NOT_FOUND` when the package is not in this tenant. `assignments` counts live holders and `automaticAssignments` those the rule assigned. A rule package also returns `autoAssign` with the owner's name, `status` (`active` or `suspended`, with the reason), advice in `warnings`, and the 20 newest problems in `issues`. ```ts title="Signature" iam.api.packages.get( credential: CredentialInput, input: { tenantId: string; packageId: string }, ): Promise ``` ## list [#list] Lists the tenant's packages by name, each with its live holder counts. **HTTP:** `POST /api/iam/packages/list` (requires a credential) · **Browser client:** `client.packages.list()` * **Permission:** `iam:packages:read` on the tenant. * **Audited as:** `iam:packages:read`. ```ts title="Signature" iam.api.packages.list( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listApprovals [#listapprovals] Lists the pending package requests you may decide on. **HTTP:** `POST /api/iam/packages/listApprovals` (requires a credential) · **Browser client:** `client.packages.listApprovals()` * **Permission:** `iam:packages:approve` on the tenant. * **Audited as:** `iam:packages:approve`. Use it for an approver's inbox. Each request is included only when its package is still requestable, you hold `iam:packages:approve` on that package, you satisfy the package's approver rules, and the request is not your own. ```ts title="Signature" iam.api.packages.listApprovals( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listAssignments [#listassignments] Lists the holders of a package, or the packages of a person, newest first. **HTTP:** `POST /api/iam/packages/listAssignments` (requires a credential) · **Browser client:** `client.packages.listAssignments()` * **Permission:** `iam:packages:read` on the package when you pass `packageId`, otherwise on the tenant. * **Audited as:** `iam:packages:read`. * **Errors:** `NOT_FOUND` when `packageId` is not in this tenant; `INVALID_INPUT` when `source` is not `automatic` or `manual`. Ended assignments are included only with `includeExpired: true`. Each entry names the package and person and reports `expired`, `broken` (its bindings no longer grant), and `automatic` (assigned by the rule). ```ts title="Signature" iam.api.packages.listAssignments( credential: CredentialInput, input: { tenantId: string; packageId?: string; identityId?: string; includeExpired?: boolean; source?: 'automatic' | 'manual'; }, ): Promise ``` ## listMine [#listmine] Returns your self-service view: the requestable packages with your status on each, your assignments, and your recent requests. **HTTP:** `POST /api/iam/packages/listMine` (requires a credential) · **Browser client:** `client.packages.listMine()` * **Permission:** `iam:packages:request` on the tenant. * **Audited as:** `iam:packages:request`. Each requestable package comes with its roles and groups named, the approver group's name, and your live `assignment` or `pending` request, which is enough to render "held", "awaiting approval", or "request". A package's rule is never shown here. `requests` holds your 50 most recent requests. Every requestable package is listed, even one you lack `iam:packages:request` on, so `request` can still refuse it. ```ts title="Signature" iam.api.packages.listMine( credential: CredentialInput, input: { tenantId: string }, ): Promise<{ packages: { assignment: PackageAssignmentSummary | undefined; pending: PackageRequestSummary | undefined; approverGroupName?: string | undefined; roles: { id: string; name: string }[]; groups: { id: string; name: string }[]; name: string; description?: string; maxDurationMs?: number; requireJustification?: boolean; requestable?: boolean; approverGroupId?: string; managerApproval?: boolean; createdAt: number; updatedAt: number; id: string; tenantId: string; }[]; assignments: PackageAssignmentSummary[]; requests: PackageRequestSummary[]; }> ``` ## listRequests [#listrequests] Lists package requests for a package or a person, newest first, optionally by status. **HTTP:** `POST /api/iam/packages/listRequests` (requires a credential) · **Browser client:** `client.packages.listRequests()` * **Permission:** `iam:packages:read` on the package when you pass `packageId`, otherwise on the tenant. * **Audited as:** `iam:packages:read`. * **Errors:** `INVALID_INPUT` for an unknown `status`; `NOT_FOUND` when `packageId` is not in this tenant. A pending request past its lapse time is reported, and filtered, as `expired` even before the purge worker marks it. ```ts title="Signature" iam.api.packages.listRequests( credential: CredentialInput, input: { tenantId: string; packageId?: string; identityId?: string; status?: PackageRequestStatus; }, ): Promise ``` ## previewAutoAssign [#previewautoassign] Shows what a package rule matches and, for a package, what a reconcile would change, without writing anything. **HTTP:** `POST /api/iam/packages/previewAutoAssign` (requires a credential) · **Browser client:** `client.packages.previewAutoAssign()` * **Permission:** `iam:packages:read` on the package (or the tenant without `packageId`), plus `iam:identities:read` on the tenant to evaluate a rule. * **Audited as:** `iam:packages:read`. * **Errors:** `INVALID_INPUT` or `INVALID_POLICY` for a candidate rule that does not validate; `ACCESS_DENIED` without `iam:identities:read`; `NOT_FOUND` when `packageId` is not in this tenant. Pass a candidate `autoAssign` to test a rule before saving it, `packageId` alone to inspect the stored rule, or neither to get only `keys`, the attribute keys a rule may test with their operators. The result counts `matching`, `excluded`, and `frozen` (disabled or expired) identities and lists a `sample` of matches (20 by default, at most 100\). With `packageId` it adds the `plan` (how many would be assigned, refreshed, restored, ended, or revoked), the first changes, whether the `brake` would hold them back, and the rule's current `status`. `warnings` flags clauses that also match service accounts or accept unverified email addresses. ```ts const preview = await iam.api.packages.previewAutoAssign(credential, { tenantId, packageId, autoAssign: { include: [ { StringEqualsIgnoreCase: { 'identity.emailDomain': 'acme.com' }, Bool: { 'identity.emailVerified': true }, StringEquals: { 'principal.kind': 'user' }, }, ], }, }); // preview.plan: { assign, refresh, restore, ending, revoke, manual, keep } // preview.brake.grants.trips: true when a scheduled run would hold the grants back ``` ```ts title="Signature" iam.api.packages.previewAutoAssign( credential: CredentialInput, input: { tenantId: string; packageId?: string; autoAssign?: AutoAssignInput; sample?: number; }, ): Promise ``` ## reconcile [#reconcile] Runs the package-rule reconciler now for the tenant or one package, optionally confirming held-back changes. **HTTP:** `POST /api/iam/packages/reconcile` (requires a credential) · **Browser client:** `client.packages.reconcile()` * **Permission:** `iam:packages:assign` on the package (or the tenant without `packageId`). `confirm: true` also needs the rights to assign the package by hand. * **Audited as:** `iam:packages:assign`; each change as `package:auto-assign`, `package:auto-ending`, or `package:auto-revoke`, and problems as `package:auto-failed`, `package:auto-suspended`, or `package:auto-braked`, all by `deployment-operator`; a confirmation as `package:auto-confirm` by you. * **Errors:** `INVALID_INPUT` when `confirm` is given without `packageId` or `limit` is outside 1 to 10000; `INVALID_TRANSITION` when confirming a package that has no rule; `ACCESS_DENIED` or `GRANT_AUTHORITY_REQUIRED` when confirming without the rights. Use it after a bulk import or a configuration apply (which does not reconcile), or to apply changes the brake held back. Without `packageId` it covers every rule package in the tenant, plus packages whose rule was cleared but that still have automatic holders. Removals run first, then additions, each change in its own transaction, at most `limit` changes (1000 by default). Without `confirm`, a run holds back more than the rule's `maxGrants` (100) new grants or `maxRemovals` (25) removals per package and reports them in `braked`. `confirm: true` approves the package's planned counts for a day and applies them in this run. Failures for one person, such as a separation-of-duties conflict, are reported in `failed` and never stop the run; `truncated: true` means the budget ran out, so run again. ```ts // A scheduled run held back 140 new grants after a bulk import. Review, then confirm. const result = await iam.api.packages.reconcile(credential, { tenantId, packageId, confirm: true }); ``` ```ts title="Signature" iam.api.packages.reconcile( credential: CredentialInput, input: { tenantId: string; packageId?: string; confirm?: boolean; limit?: number }, ): Promise ``` ## request [#request] Asks for a requestable package for yourself; approvers are notified and one of them decides. **HTTP:** `POST /api/iam/packages/request` (requires a credential) · **Browser client:** `client.packages.request()` * **Permission:** `iam:packages:request` on the package, from an ordinary session of the tenant. * **Audited as:** `iam:packages:request`, plus `package:request` with the lapse time, requested end, and justification. * **Errors:** `INVALID_TRANSITION` when the package is not requestable, or it names approvers but none could act (an empty approver group and no active manager); `CONFLICT` when you already hold the package or a request of yours is still pending; `INVALID_INPUT` from a role session or another tenant's session, or when the end or justification the package requires is missing or out of range; `IMPERSONATION_RESTRICTED` from an impersonation session. `expiresAt` is the end you want the access to have, and `justification` the reason, both under the same rules as `assign`. The request lapses after the tenant's `approvalLifetimeMs`, or at the end you asked for if that comes first. When the deployment sends email, the approver group's members and, with `managerApproval`, your manager receive a `package-request` message. Grant `iam:packages:request` through a group everyone belongs to, like `iam:bindings:activate` for [just-in-time elevation](/docs/guides/privileged-access/elevation). ```ts const pending = await iam.api.packages.request(memberCredential, { tenantId, packageId, expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, justification: 'On call for the payments team next month', }); // pending.status === 'pending'; pending.expiresAt is when the request lapses ``` ```ts title="Signature" iam.api.packages.request( credential: CredentialInput, input: { tenantId: string; packageId: string; expiresAt?: number; justification?: string; }, ): Promise ``` ## revoke [#revoke] Removes a package from a person, taking away exactly the bindings and memberships the assignment still owns. **HTTP:** `POST /api/iam/packages/revoke` (requires a credential) · **Browser client:** `client.packages.revoke()` * **Permission:** `iam:packages:assign` on the package. * **Audited as:** `iam:packages:assign`, plus `package:revoke` with the removed counts. * **Errors:** `NOT_FOUND` when the package is not assigned to the person; `INVALID_TRANSITION` for an automatic assignment while the package still has its rule; `INVARIANT_VIOLATION` when the removal would break an enforced invariant. The assignment owns its records whichever authority issued them, so revoking needs no grant authority. Records taken over by hand stay, and a shared membership passes to another of the person's packages that includes the group. To stop a rule from giving someone the package, exclude them in the rule (for example `exclude: [{ StringEquals: { 'principal.id': identityId } }]`) or change their attributes. ```ts title="Signature" iam.api.packages.revoke( credential: CredentialInput, input: { tenantId: string; packageId: string; identityId: string }, ): Promise<{ bindings: number; memberships: number; revoked: true }> ``` ## update [#update] Changes a package's name, description, contents, request settings, or rule. **HTTP:** `POST /api/iam/packages/update` (requires a credential) · **Browser client:** `client.packages.update()` * **Permission:** `iam:packages:update` on the package. Setting, changing, or clearing a rule, or changing a rule package's roles or groups, also needs `iam:packages:assign` and, except when clearing, the rule-owner checks of `create`. * **Audited as:** `iam:packages:update`, plus `package:auto-rule` (`set`, `change`, `owner`, `contents`, or `clear`) when the rule changes. * **Errors:** `CONFLICT` for a name another package uses; `INVALID_INPUT` when the rule no longer fits new contents, `keepAutomaticAssignments` is used without clearing a rule or with more than 5000 automatic holders, or `maxDurationMs` is set on a rule package; plus the validation and owner-check errors of `create`. Manual assignments keep what they were given; contents changes apply to future assignments only. Automatic assignments follow the package: the reconcile that runs after the save (up to 200 changes, the rest at the next run) adds and removes their records. Changing a rule package's roles or groups makes you the rule's owner, so a role added later is always granted under the authority of the person who added it. `autoAssign: null` clears the rule, and reconciliation then removes the automatic holders unless you pass `keepAutomaticAssignments: true`, which turns them into manual assignments. Pass `null` to clear `description`, `maxDurationMs`, or `approverGroupId`. Tightening a package cancels the pending requests it no longer allows, with the reason as the note: turning `requestable` off, requiring a justification a request lacks, or capping the duration below what a request asked for. ```ts title="Signature" iam.api.packages.update( credential: CredentialInput, input: { tenantId: string; packageId: string } & PackageUpdate, ): Promise ``` # policies (/docs/reference/api/policies) > Policies are named, versioned policy documents that roles attach to grant access. Policies are named, versioned policy documents that roles attach to grant access. A policy grants nothing on its own: it takes effect when a role lists it in `policyIds` and that role is bound to someone. Storing a document as a policy, instead of inline in a role, lets several roles share it, keeps every change as a version you can list and restore, and lets editors test a draft before saving it. This group also holds the administrator-only review reads (`simulate`, `whoCan`, `effectiveActions`) that explain access without creating a session or granting anything. The document format itself is described in [policy documents](/docs/guides/authorization/policies). ## Versions and edit rights [#versions-and-edit-rights] * **Every change is a version.** A policy starts at version 1. Each `update` or `restoreVersion` archives the current version and saves the next one, so `listVersions` always shows the full history. * **Optimistic concurrency.** `update` takes the `version` you read and fails with `VERSION_CONFLICT` when someone saved in between, so two editors cannot silently overwrite each other. * **Edits stay with their authority.** A policy records the [grant authority](/docs/guides/authorization/roles#grant-authorities) it was created under. Only that authority's holder, or root, may update, restore, or delete it, and its ceiling keeps bounding the policy wherever it is attached, even when a higher authority attaches it to a role. * **Validated against the catalog.** Documents are checked when they are created, updated, or restored: `INVALID_POLICY` for a malformed document, `INVALID_ACTION` for an unknown action, and `INVALID_RESOURCE_TYPE` for an unknown resource type. * **The Owner policy is protected.** The system Owner policy behind every tenant's Owner role cannot be edited, restored, or deleted (`PROTECTED_RESOURCE`). ## Reading a review result [#reading-a-review-result] `simulate`, `whoCan`, and `effectiveActions` evaluate an identity in a synthetic session that is never issued: a user session (an API-key session for service accounts), without MFA unless you pass `assumeMfa: true`. The ordinary evaluator runs, so conditions, boundaries, authority ceilings, access windows, relationships, and just-in-time eligibility (only live activations count) all apply. Results are advisory: they explain access, they never enforce it. See [access reviews](/docs/guides/authorization/reviews). | Reason | Meaning | | --------------------- | -------------------------------------------------------------------------------------------- | | `allowed` | A role grants the action and no deny or boundary blocks it. | | `explicit-deny` | A deny statement in one of the identity's roles matched. | | `boundary-deny` | A tenant boundary, principal boundary, or session policy does not allow the action. | | `NO_APPLICABLE_GRANT` | No role grants the action within its authority ceilings. | | `no-grant` | Returned by `test` only: the tested document does not allow the action. | | `ROOT_OVERRIDE` | The identity is a platform root administrator evaluated with MFA; the root override applies. | | `TENANT_INACTIVE` | The tenant or one of its ancestors is not active. | | `UNKNOWN_ACTION` | Returned by `simulate` only: the action is not in the catalog. | | Method | What it does | Access | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`create`](#create) | Stores a new policy document at version 1 under your grant authority. | Credential | | [`delete`](#delete) | Deletes a policy that no role attaches. | Credential | | [`effectiveActions`](#effectiveactions) | Lists which actions one identity could perform on one resource, with a reason per action. | Credential | | [`get`](#get) | Returns one policy with its current document and version. | Credential | | [`list`](#list) | Lists every policy in the tenant at its current version, including the protected Owner policy. | Credential | | [`listVersions`](#listversions) | Returns a policy's full history, oldest first, ending with the current version. | Credential | | [`restoreVersion`](#restoreversion) | Rolls a policy back to an earlier document by saving that document as a new version. | Credential | | [`simulate`](#simulate) | Explains the decision one identity would get for one action on one resource, without creating a session or granting anything. | Credential | | [`test`](#test) | Evaluates an unsaved policy document against one action, resource, and context, for policy editors. | Credential | | [`update`](#update) | Saves a new version of a policy's document, name, or description, keeping the previous version in its history. | Credential | | [`whoCan`](#whocan) | Lists every active identity that could perform an action on a resource, with the reason, for access reviews. | Credential | ## create [#create] Stores a new policy document at version 1 under your grant authority. **HTTP:** `POST /api/iam/policies/create` (requires a credential) · **Browser client:** `client.policies.create()` * **Permission:** `iam:policies:create` on the tenant, plus an active grant authority. * **Audited as:** `iam:policies:create`. * **Errors:** `INVALID_POLICY`, `INVALID_ACTION`, or `INVALID_RESOURCE_TYPE` when the document does not validate against the catalog; `GRANT_AUTHORITY_REQUIRED` when you hold no active grant authority; `LIMIT_EXCEEDED` when the tenant's plan limit for policies is reached; `INVALID_INPUT` for an empty name or a description over 512 characters. The new policy grants nothing until a role attaches it with [`roles.create`](/docs/reference/api/roles#create) or [`roles.update`](/docs/reference/api/roles#update). Try a draft with `test` first. ```ts const readOwn = await iam.api.policies.create(credential, { tenantId, name: 'Read own documents', document: { version: 1, statements: [ { sid: 'OwnedDocuments', effect: 'allow', actions: ['documents:read'], resources: ['document/*'], conditions: { StringEquals: { 'resource.ownerId': '${principal.id}' } }, }, ], }, }); ``` ```ts title="Signature" iam.api.policies.create( credential: CredentialInput, input: PolicyInput, ): Promise ``` ## delete [#delete] Deletes a policy that no role attaches. **HTTP:** `POST /api/iam/policies/delete` (requires a credential) · **Browser client:** `client.policies.delete()` * **Permission:** `iam:policies:delete` on the policy, and the policy's grant authority (or root). * **Audited as:** `iam:policies:delete`. * **Errors:** `RESOURCE_IN_USE` (409) while any role still attaches the policy; `PROTECTED_RESOURCE` for the Owner policy; `ACCESS_DENIED` when another administrator's authority created it; `NOT_FOUND`. Detach the policy first by updating each role's `policyIds`. The refusal exists so that deleting a policy never silently removes access from the roles built on it. ```ts title="Signature" iam.api.policies.delete( credential: CredentialInput, input: { tenantId: string; policyId: string }, ): Promise<{ deleted: boolean }> ``` ## effectiveActions [#effectiveactions] Lists which actions one identity could perform on one resource, with a reason per action. **HTTP:** `POST /api/iam/policies/effectiveActions` (requires a credential) · **Browser client:** `client.policies.effectiveActions()` * **Permission:** `iam:policies:simulate` on the identity (`iam/{identityId}`). * **Audited as:** `iam:policies:simulate`. * **Errors:** `INVALID_INPUT` for more than 200 `actions`; `INVALID_ACTION` when one of them is not in the catalog; `NOT_FOUND` when the identity is not in this tenant or a managed resource is not registered; `RESOURCE_RESOLVER_REQUIRED`. Without `actions`, every action in the catalog is checked: the built-in `iam:*` actions, your product's and plugins' actions, and tenant-defined ones. The identity's grants are loaded once, so this is cheaper than calling `simulate` per action. The result has `allowed` (the sorted action names) and `results` (each action with `allowed` and `reason`), which is what a "what can this person do here?" panel needs. ```ts title="Signature" iam.api.policies.effectiveActions( credential: CredentialInput, input: { tenantId: string; identityId: string; resource: { type: string; id: string }; actions?: string[]; assumeMfa?: boolean; }, ): Promise<{ allowed: string[]; results: { action: string; allowed: boolean; reason: string }[]; }> ``` ## get [#get] Returns one policy with its current document and version. **HTTP:** `POST /api/iam/policies/get` (requires a credential) · **Browser client:** `client.policies.get()` * **Permission:** `iam:policies:read` on the policy. * **Audited as:** `iam:policies:read`. * **Errors:** `NOT_FOUND` when the policy is not in this tenant. ```ts title="Signature" iam.api.policies.get( credential: CredentialInput, input: { tenantId: string; policyId: string }, ): Promise ``` ## list [#list] Lists every policy in the tenant at its current version, including the protected Owner policy. **HTTP:** `POST /api/iam/policies/list` (requires a credential) · **Browser client:** `client.policies.list()` * **Permission:** `iam:policies:read` on the tenant. * **Audited as:** `iam:policies:read`. ```ts title="Signature" iam.api.policies.list( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listVersions [#listversions] Returns a policy's full history, oldest first, ending with the current version. **HTTP:** `POST /api/iam/policies/listVersions` (requires a credential) · **Browser client:** `client.policies.listVersions()` * **Permission:** `iam:policies:read` on the policy. * **Audited as:** `iam:policies:read`. * **Errors:** `NOT_FOUND` when the policy is not in this tenant. Each entry carries the `document`, `name`, and `version` as they were. Archived entries have their own record `id` and point back to the policy through `policyId`, so pick a version for `restoreVersion` by its `version` number. ```ts title="Signature" iam.api.policies.listVersions( credential: CredentialInput, input: { tenantId: string; policyId: string }, ): Promise ``` ## restoreVersion [#restoreversion] Rolls a policy back to an earlier document by saving that document as a new version. **HTTP:** `POST /api/iam/policies/restoreVersion` (requires a credential) · **Browser client:** `client.policies.restoreVersion()` * **Permission:** `iam:policies:update` on the policy, and the policy's grant authority (or root). * **Audited as:** `iam:policies:update`. * **Errors:** `INVALID_INPUT` when `version` is not between 1 and the current version, or is the current version; `NOT_FOUND` when that version is not in the history; `INVALID_ACTION` or `INVALID_RESOURCE_TYPE` when the old document no longer fits the catalog; `PROTECTED_RESOURCE`; `ACCESS_DENIED`; `INVARIANT_VIOLATION`. History is never rewritten: restoring version 3 of a policy at version 7 saves version 8 with version 3's document. Only the document is restored; the name and description stay as they are. The old document is validated again because actions or resource types may have been removed since it was written. ```ts title="Signature" iam.api.policies.restoreVersion( credential: CredentialInput, input: { tenantId: string; policyId: string; version: number }, ): Promise<{ document: PolicyDocument; version: number; name: string; description?: string; id: string; tenantId: string; uniqueKey?: string; }> ``` ## simulate [#simulate] Explains the decision one identity would get for one action on one resource, without creating a session or granting anything. **HTTP:** `POST /api/iam/policies/simulate` (requires a credential) · **Browser client:** `client.policies.simulate()` * **Permission:** `iam:policies:simulate` on the identity (`iam/{identityId}`). * **Audited as:** `iam:policies:simulate`. * **Errors:** `NOT_FOUND` when the identity is not in this tenant or a managed resource is not registered; `RESOURCE_RESOLVER_REQUIRED` for an application-owned resource type when no `resolveResource` is configured; `RESOURCE_MISMATCH` when the resolver returns a record of another tenant or resource. Use it when support asks "why can't this person open this?". Unlike the public authorization calls, the result includes `matched`, the statements that decided. An action missing from the catalog returns `allowed: false` with reason `UNKNOWN_ACTION` rather than an error. For `iam:*` actions, pass platform resources such as `{ type: 'iam', id: roleId }`. ```ts const decision = await iam.api.policies.simulate(credential, { tenantId, identityId: alice.id, action: 'invoices:approve', resource: { type: 'invoice', id: 'inv_2041' }, assumeMfa: true, }); // { allowed: false, reason: 'explicit-deny', matched: [...] } ``` ```ts title="Signature" iam.api.policies.simulate( credential: CredentialInput, input: { tenantId: string; identityId: string; action: string; resource: { type: string; id: string }; assumeMfa?: boolean; }, ): Promise ``` ## test [#test] Evaluates an unsaved policy document against one action, resource, and context, for policy editors. **HTTP:** `POST /api/iam/policies/test` (requires a credential) · **Browser client:** `client.policies.test()` * **Permission:** `iam:policies:simulate` on the tenant. * **Audited as:** `iam:policies:simulate`. * **Errors:** `INVALID_POLICY`, `INVALID_ACTION`, or `INVALID_RESOURCE_TYPE` when the document does not validate; `INVALID_INPUT` when `context` has more than 200 keys or `resource` is over 2048 characters. Only the document is evaluated: no identity, roles, bindings, or boundaries are involved, so it answers "does this document say what I mean?". `resource` is a `type/id` string. The context starts with the keys a session issued now would carry (`resource.tenantId` and `principal.tenantId` set to the tenant, `principal.sessionId`, `principal.tokenIssueTime`, `principal.authTime`, `principal.sessionTagKeys`, and `request.time`); your `context` adds keys or overrides them. Add `'principal.mfa': true` to test an MFA condition and `principal.id` to resolve `${principal.id}` variables. The result is a full decision with `matched`, the statements that matched as `grant:{index}:{sid}` (the statement's position when it has no `sid`). ```ts const decision = await iam.api.policies.test(credential, { tenantId, document: draft, action: 'documents:read', resource: 'document/plan-2027', context: { 'principal.id': 'usr_123', 'resource.ownerId': 'usr_123' }, }); // { allowed: true, reason: 'allowed', matched: ['grant:0:OwnedDocuments'] } ``` ```ts title="Signature" iam.api.policies.test( credential: CredentialInput, input: { tenantId: string; document: PolicyDocument; action: string; resource: string; context?: Record; }, ): Promise ``` ## update [#update] Saves a new version of a policy's document, name, or description, keeping the previous version in its history. **HTTP:** `POST /api/iam/policies/update` (requires a credential) · **Browser client:** `client.policies.update()` * **Permission:** `iam:policies:update` on the policy, and the grant authority the policy was created under (or root). * **Audited as:** `iam:policies:update`. * **Errors:** `VERSION_CONFLICT` (409) when `version` is not the current version; `ACCESS_DENIED` when another administrator's authority created the policy; `PROTECTED_RESOURCE` for the Owner policy; `INVALID_INPUT` when none of `document`, `name`, or `description` is given; `INVALID_POLICY`, `INVALID_ACTION`, or `INVALID_RESOURCE_TYPE` for the new document; `GRANT_AUTHORITY_REQUIRED`; `NOT_FOUND`; `INVARIANT_VIOLATION` when the change would newly break an enforced [access invariant](/docs/reference/api/invariants). Pass the `version` you read. Renaming also creates a new version. The change applies at the next request to every role that attaches the policy, so preview it first with [`impact.preview`](/docs/reference/api/impact#preview). ```ts const current = await iam.api.policies.get(credential, { tenantId, policyId }); await iam.api.policies.update(credential, { tenantId, policyId, version: current.version, document: { version: 1, statements: [ ...current.document.statements, { effect: 'deny', actions: ['documents:delete'], resources: ['document/*'] }, ], }, }); ``` ```ts title="Signature" iam.api.policies.update( credential: CredentialInput, input: PolicyUpdate, ): Promise ``` ## whoCan [#whocan] Lists every active identity that could perform an action on a resource, with the reason, for access reviews. **HTTP:** `POST /api/iam/policies/whoCan` (requires a credential) · **Browser client:** `client.policies.whoCan()` * **Permission:** `iam:policies:simulate` on the tenant. * **Audited as:** `iam:policies:simulate`. * **Errors:** `INVALID_ACTION` for an action missing from the catalog; `INVALID_INPUT` for a `kind` other than `user` or `service`, a `limit` outside 1 to 1000, or an invalid `offset`; `NOT_FOUND` when a managed resource is not registered; `RESOURCE_RESOLVER_REQUIRED`. Every active identity of the tenant is evaluated, people and service accounts alike; `kind` narrows to one. The result has `identities` (id, name, email, kind, and the decision reason) and `total`, the number of matches before paging with `limit` (100 by default) and `offset`. Grants are loaded per identity, so the cost grows with the directory: use it on review screens, not on every request. ```ts title="Signature" iam.api.policies.whoCan( credential: CredentialInput, input: { tenantId: string; action: string; resource: { type: string; id: string }; kind?: 'user' | 'service'; assumeMfa?: boolean; limit?: number; offset?: number; }, ): Promise<{ identities: ReviewMatch[]; total: number }> ``` # relationships (/docs/reference/api/relationships) > Relationships record that a person or group stands in a named relation, such as owner or viewer, to one resource. Relationships record that a person or group stands in a named relation, such as `owner` or `viewer`, to one resource. *Alice is an `owner` of `folder/plans`*; *the design group are `viewer`s of `folder/plans`*. They express per-resource sharing and ownership (relationship-based access control) without writing resource ids into policies: one role says "viewers may read", and sharing a folder is a single tuple instead of a policy edit. The [relationships guide](/docs/guides/authorization/relationships) shows the full pattern. ## How policies read relations [#how-policies-read-relations] A tuple is `{type}/{id}#{relation}@{subjectType}:{subjectId}`. The relation must be declared on the resource type (`relations` in `permissions.resourceTypes`, or on a [tenant-defined type](/docs/reference/api/resource-types#register)). The subject is an identity or a group; a group's tuples apply to its current members. When a decision is made, the caller's live relations on the evaluated resource appear as `resource.relations` (a sorted array of names) and those on its registered parent as `resource.parentRelations`. Test them with `ArrayContains`: ```ts { effect: 'allow', actions: ['files:read'], resources: ['file/*'], conditions: { ArrayContains: { 'resource.parentRelations': ['viewer', 'editor', 'owner'] } } } ``` Administrative calls on `iam/{type}/{id}` see the relations on the named resource too, which is how an owner can share their own folder without a tenant-wide administrator role: grant `iam:relationships:create` on `iam/folder/*` under the condition `ArrayContains: { 'resource.relations': ['owner'] }`. Role sessions hold no relations. Expired tuples stop counting at once and are removed later by `iam.sweepExpired()`. Tuples are also removed with their identity, their group, or their managed resource. [`listAccessible`](/docs/reference/api#listaccessible) takes relations into account. | Method | What it does | Access | | ------------------- | ---------------------------------------------------------------------------------------------------- | ---------- | | [`create`](#create) | Gives an identity or group a declared relation on one resource, optionally until a given time. | Credential | | [`delete`](#delete) | Removes one relationship tuple, ending the access it gave. | Credential | | [`list`](#list) | Lists relationship tuples of one resource, one subject, one type, or the whole tenant, newest first. | Credential | ## create [#create] Gives an identity or group a declared relation on one resource, optionally until a given time. **HTTP:** `POST /api/iam/relationships/create` (requires a credential) · **Browser client:** `client.relationships.create()` * **Permission:** `iam:relationships:create` on `iam/{type}/{id}`. * **Audited as:** `iam:relationships:create`, on `{type}/{id}`. * **Errors:** `INVALID_RESOURCE_TYPE` when the type is not declared; `INVALID_INPUT` when the relation is not declared for the type, the subject type is not `identity` or `group`, or `expiresAt` is not in the future (at most ten years out); `NOT_FOUND` when a managed resource is not registered, the identity is not in this tenant or was deleted, or the group is not in this tenant; `INVARIANT_VIOLATION` when an enforced [access invariant](/docs/guides/governance/change-safety) would newly fail. Resources of managed types must be registered first; resources of application-owned types are accepted as named. Creating a tuple that already exists replaces it instead of failing: its `expiresAt` becomes the one you pass (none makes it permanent) and the caller is recorded as `createdBy`. Use `expiresAt` for time-boxed sharing, such as giving an auditor `viewer` on a folder for a week. ```ts await iam.api.relationships.create(credential, { tenantId, type: 'folder', id: 'plans', relation: 'viewer', subjectType: 'group', subjectId: designGroupId, expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, }); ``` ```ts title="Signature" iam.api.relationships.create( credential: CredentialInput, input: { tenantId: string; type: string; id: string; relation: string; subjectType: 'identity' | 'group'; subjectId: string; expiresAt?: number; }, ): Promise ``` ## delete [#delete] Removes one relationship tuple, ending the access it gave. **HTTP:** `POST /api/iam/relationships/delete` (requires a credential) · **Browser client:** `client.relationships.delete()` * **Permission:** `iam:relationships:delete` on the tuple's resource, `iam/{type}/{id}`. * **Audited as:** `iam:relationships:delete`, on `{type}/{id}`. * **Errors:** `NOT_FOUND` when the tuple is not in this tenant; `INVARIANT_VIOLATION` when an enforced access invariant would newly fail. Pass the tuple's `id`, as returned by [`create`](#create) or [`list`](#list). The change applies to the next decision. ```ts title="Signature" iam.api.relationships.delete( credential: CredentialInput, input: { tenantId: string; relationshipId: string }, ): Promise<{ deleted: boolean }> ``` ## list [#list] Lists relationship tuples of one resource, one subject, one type, or the whole tenant, newest first. **HTTP:** `POST /api/iam/relationships/list` (requires a credential) · **Browser client:** `client.relationships.list()` * **Permission:** `iam:relationships:read` on `iam/{type}/{id}` when `type` and `id` are given, on `iam/{type}/*` when only `type` is, otherwise on `iam/*`. * **Audited as:** `iam:relationships:read`. * **Errors:** `INVALID_INPUT` for a subject type other than `identity` or `group`. Filter by `type`, `id`, `relation`, `subjectType`, and `subjectId` in any combination: "who can see this folder" is `{ type, id }`, and "what has been shared with this group" is `{ subjectType: 'group', subjectId }`. Expired tuples are left out unless `includeExpired` is `true`. Because the permission is checked on the resource, an owner allowed to read relationships on their own folder can review who it is shared with. ```ts title="Signature" iam.api.relationships.list( credential: CredentialInput, input: { tenantId: string; type?: string; id?: string; relation?: string; subjectType?: 'identity' | 'group'; subjectId?: string; includeExpired?: boolean; }, ): Promise ``` # reports (/docs/reference/api/reports) > The access report gathers a tenant's access-lifecycle state in one document: what is about to end, who is elevated right now, and which API keys nobody uses. The access report gathers a tenant's access-lifecycle state in one document: what is about to end, who is elevated right now, and which API keys nobody uses. Temporary access only stays safe if someone notices what is expiring, what is pending, and what was forgotten, and checking each list by hand does not scale. Run the report nightly and route it to a channel or ticketing system. See [the access report](/docs/guides/privileged-access/access-report). | Method | What it does | Access | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | | [`access`](#access) | Returns the tenant's access report: identities and grants ending soon, live elevations, pending requests, and unused or expiring API keys. | Credential | ## access [#access] Returns the tenant's access report: identities and grants ending soon, live elevations, pending requests, and unused or expiring API keys. **HTTP:** `POST /api/iam/reports/access` (requires a credential) · **Browser client:** `client.reports.access()` * **Permission:** `iam:identities:read` on the tenant. The bindings section also needs `iam:bindings:read` and the credentials section `iam:credentials:read` on the tenant; without them the section is left out, not refused. * **Audited as:** `iam:identities:read`. * **Errors:** `INVALID_INPUT` when `withinMs` or `unusedForMs` is negative or longer than ten years. `withinMs` (default 30 days) is the look-ahead window for things that end or start, and `unusedForMs` (default 30 days) is how long an API key must go unused to be listed. The report has three sections: * **`identities`**: how many identities the tenant has (deleted ones excluded) and how many are disabled, plus people and service accounts whose `expiresAt` falls within the window. An identity already past its deadline stays listed with `expired: true` until the retention worker disables it. * **`bindings`**: how many unexpired bindings there are, and how many of them are eligible or limited to an access window; bindings that expire within the window; future-dated bindings that start within it; temporary group memberships that end within it; live just-in-time activations with their justification; and the number of activation requests awaiting a decision. * **`credentials`**: the number of unexpired API keys, keys not used for `unusedForMs` (never-used keys count from their creation), and keys that expire within the window. `omitted` names the sections the caller could not read, so a directory administrator without `iam:credentials:read` still gets the rest. For email delivery to tenant owners, schedule [`iam.sendAccessDigest()`](/docs/reference/api#sendaccessdigest) instead; the `report` [CLI command](/docs/reference/cli#report) prints the same report. ```ts const report = await iam.api.reports.access(credential, { tenantId, withinMs: 14 * 24 * 60 * 60 * 1000 }); for (const binding of report.bindings?.expiring ?? []) console.log(`${binding.subjectName ?? binding.subjectId} loses ${binding.roleName ?? binding.roleId}`); ``` ```ts title="Signature" iam.api.reports.access( credential: CredentialInput, input: { tenantId: string; withinMs?: number; unusedForMs?: number }, ): Promise ``` # resourceTypes (/docs/reference/api/resource-types) > Resource types let a tenant describe its own kinds of resources at runtime, with their actions, typed attributes, relations, and parent. Resource types let a tenant describe its own kinds of resources at runtime, with their actions, typed attributes, relations, and parent. Platform types come from your configuration (`permissions.resourceTypes`) and plugins; this group lists them all and, when the deployment sets `permissions.mode: 'tenant-defined'`, lets tenant administrators add types of their own without a redeploy. That suits products where each customer models different objects, such as a workflow tool where one organization tracks "contracts" and another "incidents". The [catalog guide](/docs/guides/authorization/catalog#tenant-defined-catalogs) explains the model. ## What a tenant-defined type declares [#what-a-tenant-defined-type-declares] A tenant-defined type is always **managed**: its resources are registered through [`resources.register`](/docs/reference/api/resources#register) and authorization reads them without an application callback. It declares: * `name`: lowercase letters, digits, and hyphens, starting with a letter, at most 64 characters. It cannot be a reserved name (`iam`, `role`, `tenant`, `identity`, `session`, `oauth-client`, `scim`, `saml`, `ssf`), a platform type, or the namespace of any platform action (for example `documents` when `documents:read` exists). * `actions`: verbs. Each becomes the action `{name}:{verb}`, such as `contract:approve`. Verbs start with a letter and use letters, digits, `_`, or `-`. * `attributes`: at most 64 typed attributes (`string`, `number`, or `boolean`) that registered resources may carry and policies read as `resource.{name}`. * `relations`: at most 32 lowercase relation names that [relationship tuples](/docs/reference/api/relationships) may use on resources of the type. * `parent` (optional): an existing managed type. Resources of the type must then be registered under a parent resource, and policies can read the caller's relations on that parent. Defining a type or action grants nothing. Access still comes from roles and policies that name the new actions. Types can also be managed as code with [configuration sync](/docs/guides/privileged-access/config-as-code). | Method | What it does | Access | | ----------------------- | ----------------------------------------------------------------------------------------------------------- | ---------- | | [`delete`](#delete) | Deletes a tenant-defined resource type and its actions. | Credential | | [`get`](#get) | Returns one resource type, platform or tenant-defined, with its actions, attributes, relations, and parent. | Credential | | [`list`](#list) | Lists every resource type the tenant can use: platform types first, then the tenant's own. | Credential | | [`register`](#register) | Defines a new resource type for the tenant, and registers its actions in the same transaction. | Credential | | [`update`](#update) | Changes a tenant-defined type's description, attribute schema, or relations, and adds action verbs. | Credential | ## delete [#delete] Deletes a tenant-defined resource type and its actions. **HTTP:** `POST /api/iam/resourceTypes/delete` (requires a credential) · **Browser client:** `client.resourceTypes.delete()` * **Permission:** `iam:resource-types:delete` on the tenant. * **Audited as:** `iam:resource-types:delete`. * **Errors:** `NOT_FOUND` when no tenant-defined type has that name (platform types cannot be deleted); `RESOURCE_IN_USE` while resources of the type are registered, relationship tuples reference it, another type names it as its parent, or a stored policy or inline role document still names one of its actions. The in-use checks keep deletion from silently changing access: remove the resources, relationships, child types, and policy references first. ```ts title="Signature" iam.api.resourceTypes.delete( credential: CredentialInput, input: { tenantId: string; name: string }, ): Promise<{ deleted: boolean }> ``` ## get [#get] Returns one resource type, platform or tenant-defined, with its actions, attributes, relations, and parent. **HTTP:** `POST /api/iam/resourceTypes/get` (requires a credential) · **Browser client:** `client.resourceTypes.get()` * **Permission:** `iam:resource-types:read` on the tenant. * **Audited as:** `iam:resource-types:read`. * **Errors:** `NOT_FOUND` when the name is unknown to this tenant. `source` is `platform` or `tenant`, and `managed` says whether resources of the type are registered with IAM or resolved by the application. ```ts title="Signature" iam.api.resourceTypes.get( credential: CredentialInput, input: { tenantId: string; name: string }, ): Promise ``` ## list [#list] Lists every resource type the tenant can use: platform types first, then the tenant's own. **HTTP:** `POST /api/iam/resourceTypes/list` (requires a credential) · **Browser client:** `client.resourceTypes.list()` * **Permission:** `iam:resource-types:read` on the tenant. * **Audited as:** `iam:resource-types:read`. Use it to build policy editors and resource pickers. Platform types include application-owned ones (`managed: false`), which exist for validation and documentation but are not registered through the API. ```ts title="Signature" iam.api.resourceTypes.list( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## register [#register] Defines a new resource type for the tenant, and registers its actions in the same transaction. **HTTP:** `POST /api/iam/resourceTypes/register` (requires a credential) · **Browser client:** `client.resourceTypes.register()` * **Permission:** `iam:resource-types:create` on the tenant. * **Audited as:** `iam:resource-types:create`. * **Errors:** `CATALOG_LOCKED` (403) when the deployment does not allow tenant-defined types; `INVALID_RESOURCE_TYPE` for an invalid or reserved name, or a `parent` that is not an existing managed type; `CONFLICT` when the tenant already has a type with that name; `INVALID_INPUT` for invalid attributes or relations; `INVALID_ACTION` for an invalid verb. ```ts await iam.api.resourceTypes.register(credential, { tenantId, name: 'contract', description: 'Customer contracts', actions: ['read', 'approve'], // registers contract:read and contract:approve attributes: { value: 'number', region: 'string' }, relations: ['owner', 'reviewer'], }); ``` ```ts title="Signature" iam.api.resourceTypes.register( credential: CredentialInput, input: ResourceTypeInput, ): Promise ``` ## update [#update] Changes a tenant-defined type's description, attribute schema, or relations, and adds action verbs. **HTTP:** `POST /api/iam/resourceTypes/update` (requires a credential) · **Browser client:** `client.resourceTypes.update()` * **Permission:** `iam:resource-types:update` on the tenant. * **Audited as:** `iam:resource-types:update`. * **Errors:** `NOT_FOUND` when no tenant-defined type has that name; `INVALID_INPUT` when a registered resource would no longer match the new attribute schema; `RESOURCE_IN_USE` when a relation you drop is still held by a relationship tuple; `CATALOG_LOCKED` when adding verbs while tenant-defined actions are disabled. `attributes` and `relations` replace the whole list. `actions` only adds: verbs already registered are kept and repeated ones are skipped. To remove a verb, use [`actions.unregister`](/docs/reference/api/actions#unregister). The name and parent cannot change. The schema check exists because conditions on an attribute would otherwise silently stop matching resources that no longer fit it. ```ts title="Signature" iam.api.resourceTypes.update( credential: CredentialInput, input: ResourceTypeUpdate, ): Promise ``` # resources (/docs/reference/api/resources) > This group registers the resources your product protects, so authorization can decide about them without calling back into your application. This group registers the resources your product protects, so authorization can decide about them without calling back into your application. A registration records a resource's typed attributes, its owner, and its parent, and policies read them as `resource.*` context keys. Registered resources can also be listed by what the caller may do with them (`iam.listAccessible`), which is what makes filtered list pages possible. ## Managed and application-owned resources [#managed-and-application-owned-resources] Every resource type is one of two kinds (see [managed types](/docs/guides/authorization/catalog#managed-types)): * **Application-owned** types are resolved by your application at decision time through the `resolveResource` option. Your database stays the source of truth and nothing is registered here. * **Managed** types (declared with `managed: true`, and every [tenant-defined type](/docs/reference/api/resource-types)) are registered through this group. Authorization reads the registration, so no callback is involved, and `listAccessible` can enumerate them. This group only accepts managed types; an application-owned or unknown type fails with `INVALID_RESOURCE_TYPE`. A registration is addressed by `type` and `id` (your identifier, at most 128 characters). The returned record keeps your identifier in `resourceId`; its `id` field is an internal record id. During evaluation a registered resource exposes its attributes as `resource.{name}`, plus `resource.ownerId`, `resource.parentId`, and `resource.parentType` when set, and the caller's [relations](/docs/reference/api/relationships) on it and on its parent. Administrative calls on `iam/{type}/{id}` see the same keys, so a policy can let owners manage their own resources: ```ts { effect: 'allow', actions: ['iam:resources:update', 'iam:resources:delete'], resources: ['iam/project/*'], conditions: { StringEquals: { 'resource.ownerId': '${principal.id}' } }, } ``` Attribute values must match the type's declared schema: only declared names, with the declared type (`string`, `number`, or `boolean`); strings are at most 2048 characters with no control characters. | Method | What it does | Access | | ------------------------------- | ----------------------------------------------------------------------------------------------- | ---------- | | [`delete`](#delete) | Removes a registered resource and every relationship tuple on it. | Credential | | [`get`](#get) | Returns the registration of one resource. | Credential | | [`list`](#list) | Lists registered resources, optionally of one type, under one parent, or owned by one identity. | Credential | | [`register`](#register) | Registers a managed resource with its attributes, owner, and parent. | Credential | | [`registerMany`](#registermany) | Registers up to 100 managed resources in one transaction, either all of them or none. | Credential | | [`update`](#update) | Replaces a registered resource's attributes, or changes or clears its owner. | Credential | ## delete [#delete] Removes a registered resource and every relationship tuple on it. **HTTP:** `POST /api/iam/resources/delete` (requires a credential) · **Browser client:** `client.resources.delete()` * **Permission:** `iam:resources:delete` on `iam/{type}/{id}`. * **Audited as:** `iam:resources:delete`, on `{type}/{id}`. * **Errors:** `NOT_FOUND` when the resource is not registered; `RESOURCE_IN_USE` when registered child resources still point at it; `INVARIANT_VIOLATION` when an enforced [access invariant](/docs/guides/governance/change-safety) would newly fail. Delete children first: a parent cannot be removed while resources registered under it exist. Once deleted, any decision about the resource sees no attributes, owner, or relations. ```ts title="Signature" iam.api.resources.delete( credential: CredentialInput, input: { tenantId: string; type: string; id: string }, ): Promise<{ deleted: boolean }> ``` ## get [#get] Returns the registration of one resource. **HTTP:** `POST /api/iam/resources/get` (requires a credential) · **Browser client:** `client.resources.get()` * **Permission:** `iam:resources:read` on `iam/{type}/{id}`. * **Audited as:** `iam:resources:read`, on `{type}/{id}`. * **Errors:** `NOT_FOUND` when the resource is not registered. ```ts title="Signature" iam.api.resources.get( credential: CredentialInput, input: { tenantId: string; type: string; id: string }, ): Promise ``` ## list [#list] Lists registered resources, optionally of one type, under one parent, or owned by one identity. **HTTP:** `POST /api/iam/resources/list` (requires a credential) · **Browser client:** `client.resources.list()` * **Permission:** `iam:resources:read` on `iam/{type}/*` when `type` is given, otherwise on `iam/*`. * **Audited as:** `iam:resources:read`. * **Errors:** `INVALID_INPUT` when `limit` is outside 1 to 1000. Results are sorted by `type/id`, so pages are stable and meaningful; `limit` defaults to 100, with `offset` for the next page. `parentId` is the parent's own identifier. This is an administrative listing of what exists. To list what a person may act on, use [`listAccessible`](/docs/reference/api#listaccessible). ```ts title="Signature" iam.api.resources.list( credential: CredentialInput, input: { tenantId: string; type?: string; parentId?: string; ownerId?: string; limit?: number; offset?: number; }, ): Promise ``` ## register [#register] Registers a managed resource with its attributes, owner, and parent. **HTTP:** `POST /api/iam/resources/register` (requires a credential) · **Browser client:** `client.resources.register()` * **Permission:** `iam:resources:create` on `iam/{type}/{id}`. * **Audited as:** `iam:resources:create`, on `{type}/{id}`. * **Errors:** `INVALID_RESOURCE_TYPE` when the type is unknown or application-owned; `CONFLICT` when the resource is already registered; `INVALID_INPUT` for undeclared or wrongly typed attributes, a missing `parentId` on a type that declares a parent, or a `parentId` on a type that does not; `NOT_FOUND` when the parent is not registered or `ownerId` is not an identity of the tenant (or was deleted); `LIMIT_EXCEEDED` at the tenant's resource limit. Because the permission is checked on the resource's own address, policies can limit who registers what, for example `iam/project/*` for project administrators. Register a resource when your product creates it, in the same request, so access rules apply from the first moment. The parent cannot be changed later. ```ts await iam.api.resources.register(credential, { tenantId, type: 'task', id: 'task_812', parentId: 'proj_apollo', // a registered `project`, because `task` declares `parent: 'project'` ownerId: identityId, attributes: { priority: 2 }, // the type declares `priority: 'number'` }); ``` ```ts title="Signature" iam.api.resources.register( credential: CredentialInput, input: { tenantId: string; type: string; id: string; attributes?: Record; parentId?: string; ownerId?: string; }, ): Promise ``` ## registerMany [#registermany] Registers up to 100 managed resources in one transaction, either all of them or none. **HTTP:** `POST /api/iam/resources/registerMany` (requires a credential) · **Browser client:** `client.resources.registerMany()` * **Permission:** `iam:resources:create` on `iam/{type}/{id}` for every item. * **Audited as:** `iam:resources:create`, once per item. * **Errors:** `INVALID_INPUT` for an empty list or more than 100 items; `ACCESS_DENIED` naming the first item the caller may not register (only that denial is recorded and nothing is written); `LIMIT_EXCEEDED` when the whole batch does not fit the tenant's limit; any error [`register`](#register) raises for one item rejects the batch. Every item is authorized before anything is written. Use it to import existing data or to register a parent and its children together (list the parent first). ```ts title="Signature" iam.api.resources.registerMany( credential: CredentialInput, input: { tenantId: string; resources: ResourceInput[] }, ): Promise<{ resources: ResourceRecord[] }> ``` ## update [#update] Replaces a registered resource's attributes, or changes or clears its owner. **HTTP:** `POST /api/iam/resources/update` (requires a credential) · **Browser client:** `client.resources.update()` * **Permission:** `iam:resources:update` on `iam/{type}/{id}`. * **Audited as:** `iam:resources:update`, on `{type}/{id}`. * **Errors:** `NOT_FOUND` when the resource is not registered or the new owner is not in this tenant; `INVALID_INPUT` for undeclared or wrongly typed attributes; `INVALID_RESOURCE_TYPE`; `INVARIANT_VIOLATION` when an enforced access invariant would newly fail. `attributes` replaces the whole attribute set, so send every attribute you want to keep. `ownerId: null` removes the owner. Keep registrations current when the underlying record changes: a condition such as `Bool: { 'resource.archived': false }` sees only what was last registered. Offboarding a person with [`identities.offboard`](/docs/reference/api/identities#offboard) can transfer the resources they own to a successor. ```ts title="Signature" iam.api.resources.update( credential: CredentialInput, input: { tenantId: string; type: string; id: string; attributes?: Record; ownerId?: string | null; }, ): Promise ``` # roleMining (/docs/reference/api/role-mining) > Role mining reads who holds which roles today and suggests simpler, narrower ways to grant the same access. Role mining reads who holds which roles today and suggests simpler, narrower ways to grant the same access. Over time, direct grants pile up, roles get copied, and people keep access after they change teams. This group finds those patterns, compares people with their peers, and uses recorded access usage to show which grants nobody uses, so you can clean up with evidence instead of guesswork. It is the "simplify" and "measure" part of [access governance](/docs/guides/governance/usage-and-mining). ## How role mining reads a tenant [#how-role-mining-reads-a-tenant] Suggestions, outliers, and right-sizing work on a snapshot of the tenant taken inside one transaction: active identities that have not expired, live role bindings (started and not expired), and live group memberships. A role reaches a person through their own binding or through a group they belong to. The protected Owner role is always left out, so mining never suggests touching ownership. Suggestions and findings are advisory: every read method only reads, and nothing changes until you call [`apply`](#apply) or edit roles, bindings, and packages yourself. Suggestion IDs are deterministic (the same condition always yields the same ID), so a suggestion listed earlier can be applied later as long as it still holds. All read methods need `iam:analysis:read` on `iam/analysis/*`. The console's Organization section uses the same calls, and the `mine-roles` [CLI command](/docs/reference/cli#mine-roles) prints suggestions and outliers for a weekly report. ## Access usage tracking [#access-usage-tracking] `usage`, `rightSize`, and `reviewRecommendations` rely on recorded usage, which is off until you turn it on with the `accessUsage` option (`accessUsage: true`, or `{ flushIntervalMs, maxBuffered }`). When it is on, every allowed authorization check and every allowed provisioning operation is counted in memory per person and action and written in batches (every minute by default), so the request path never waits on storage. Root overrides and actions taken during an impersonation ("view as") session are not counted as the person's own use. Usage only proves what happened since tracking started. Each result says when tracking began for the tenant, and the right-sizing and review methods tell you whether the recorded period covers the whole window you asked about. Call `iam.flushAccessUsage()` on shutdown so buffered counts are not lost. | Method | What it does | Access | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | | [`apply`](#apply) | Carries out a `group-binding` or `redundant-binding` suggestion: binds the role to the group once and removes the direct bindings it replaces. | Credential | | [`outliers`](#outliers) | Finds people whose roles differ from their peers': roles few peers hold and roles most peers hold that the person lacks. | Credential | | [`reviewRecommendations`](#reviewrecommendations) | Suggests a keep or revoke decision, with a reason, for every item of an [access-certification](/docs/guides/governance/certifications) campaign. | Credential | | [`rightSize`](#rightsize) | Lists every live binding whose holder used none or only some of the role's actions in a window, plus, per role, the actions nobody used. | Credential | | [`suggest`](#suggest) | Lists ways to simplify how the tenant grants access, most actionable first. | Credential | | [`usage`](#usage) | Returns the recorded access usage of the tenant: per person and action, when it was first and last allowed and how often. | Credential | ## apply [#apply] Carries out a `group-binding` or `redundant-binding` suggestion: binds the role to the group once and removes the direct bindings it replaces. **HTTP:** `POST /api/iam/roleMining/apply` (requires a credential) · **Browser client:** `client.roleMining.apply()` * **Permission:** `iam:analysis:update` on `iam/analysis/{suggestionId}`, plus `iam:bindings:create` on the role (for a group binding) and `iam:bindings:delete` on each removed binding. The bindings move under the grant authority they already use, so you must hold that authority or be a root administrator. * **Audited as:** `iam:analysis:update`. * **Errors:** `NOT_FOUND` when the suggestion no longer holds (list suggestions again); `INVALID_INPUT` for a `bundle` or `duplicate-roles` suggestion, or a group binding whose direct bindings come from different grant authorities (`applicable: false`); `ACCESS_DENIED` without the binding rights or the authority; `INVARIANT_VIOLATION` when an enforced [access invariant](/docs/guides/governance/change-safety) would newly break. The suggestion is recomputed inside the transaction, so it applies only while the condition still holds, and either every binding change happens or none does. Pass the same `minIdentities` and `minRoles` you used with `suggest`: the suggestion is looked up again with those settings, and different settings can make it disappear. Bundles are not applied here. Turn a bundle into an access package with [`packages.create`](/docs/reference/api/packages#create), and merge duplicate roles by moving bindings to one role and deleting the others. ```ts const { suggestions } = await iam.api.roleMining.suggest(credential, { tenantId, kinds: ['redundant-binding'] }); const result = await iam.api.roleMining.apply(credential, { tenantId, suggestionId: suggestions[0].id }); // result.removedBindingIds: the direct bindings that were deleted ``` ```ts title="Signature" iam.api.roleMining.apply( credential: CredentialInput, input: { tenantId: string; suggestionId: string; minIdentities?: number; minRoles?: number; }, ): Promise<{ applied: RoleSuggestionKind; createdBindingId: string | undefined; removedBindingIds: string[]; }> ``` ## outliers [#outliers] Finds people whose roles differ from their peers': roles few peers hold and roles most peers hold that the person lacks. **HTTP:** `POST /api/iam/roleMining/outliers` (requires a credential) · **Browser client:** `client.roleMining.outliers()` * **Permission:** `iam:analysis:read` on `iam/analysis/*`. * **Audited as:** `iam:analysis:read`. * **Errors:** `INVALID_INPUT` when `peerBy` is neither `manager` nor `attribute:NAME` for a declared identity attribute, when `threshold` or `commonShare` is not above 0 and at most 1, or when `minPeers` is out of range. Peers are people who share a manager (`peerBy: 'manager'`, the default) or the same value of a declared identity attribute (`peerBy: 'attribute:department'`). A role is **unusual** when fewer than `threshold` (default 0.25) of the person's peers hold it, which often means access that outlived a move. A role is **missing** when at least `commonShare` (default 0.8) of the peers hold it, which often means a joiner who still lacks something. Peer groups with fewer than `minPeers` other people (default 3) are skipped. Eligible (just-in-time) bindings count as held. ```ts const { outliers } = await iam.api.roleMining.outliers(credential, { tenantId, peerBy: 'attribute:department', threshold: 0.2, }); ``` ```ts title="Signature" iam.api.roleMining.outliers( credential: CredentialInput, input: { tenantId: string; peerBy?: string; threshold?: number; commonShare?: number; minPeers?: number; }, ): Promise ``` ## reviewRecommendations [#reviewrecommendations] Suggests a keep or revoke decision, with a reason, for every item of an [access-certification](/docs/guides/governance/certifications) campaign. **HTTP:** `POST /api/iam/roleMining/reviewRecommendations` (requires a credential) · **Browser client:** `client.roleMining.reviewRecommendations()` * **Permission:** `iam:analysis:read` on `iam/analysis/*`. * **Audited as:** `iam:analysis:read`. * **Errors:** `NOT_FOUND` when the campaign is not in this tenant; `INVALID_INPUT` when `unusedDays` is outside 1 to 3650. Each recommendation is based on evidence. It is `revoke` when the account is disabled, expired, or gone (`basis: 'status'`). Otherwise, when recorded usage covers the last `unusedDays` (default 90), it is `keep` if the person used any of the role's actions in that window and `revoke` if not (`basis: 'usage'`). Before usage covers the window, the person's last sign-in decides (`basis: 'sign-in'`). Items for group bindings, and service accounts without usage data, get `none`. Reviewers still decide; the console shows the suggestion beside each open item. ```ts title="Signature" iam.api.roleMining.reviewRecommendations( credential: CredentialInput, input: { tenantId: string; campaignId: string; unusedDays?: number }, ): Promise ``` ## rightSize [#rightsize] Lists every live binding whose holder used none or only some of the role's actions in a window, plus, per role, the actions nobody used. **HTTP:** `POST /api/iam/roleMining/rightSize` (requires a credential) · **Browser client:** `client.roleMining.rightSize()` * **Permission:** `iam:analysis:read` on `iam/analysis/*`. * **Audited as:** `iam:analysis:read`. * **Errors:** `INVALID_INPUT` when `unusedDays` is outside 1 to 3650. This is least-privilege right-sizing. An entry is `unused` when the holder used none of the role's actions within `unusedDays` (default 90) and `partial` when they used some. Per role, `neverUsed` lists actions no holder used, which are candidates for a narrower role. A role's actions are the known actions its allow statements (own, attached, and inherited) can match; resources and conditions are not considered. Check `complete` before acting: it is `false` until usage has been recorded for the whole window, and until then "unused" only means "not used since tracking started". `tracking` is `false` when the `accessUsage` option is off. ```ts title="Signature" iam.api.roleMining.rightSize( credential: CredentialInput, input: { tenantId: string; unusedDays?: number }, ): Promise ``` ## suggest [#suggest] Lists ways to simplify how the tenant grants access, most actionable first. **HTTP:** `POST /api/iam/roleMining/suggest` (requires a credential) · **Browser client:** `client.roleMining.suggest()` * **Permission:** `iam:analysis:read` on `iam/analysis/*`. * **Audited as:** `iam:analysis:read`. * **Errors:** `INVALID_INPUT` for an unknown kind in `kinds`, or `minIdentities` (2 to 10 000), `minRoles` (2 to 50\), or `limit` (1 to 500) out of range. There are four kinds of suggestion: * **`redundant-binding`**: direct bindings that a permanent group membership already covers, with the same role, the same grant authority, and a group binding that lasts at least as long. Removing them changes nothing today. * **`group-binding`**: a role that every member of a group (all active, all with permanent memberships) holds through their own direct binding. Bind it to the group once, so joiners get it and leavers lose it. * **`duplicate-roles`**: roles whose statements are identical. * **`bundle`**: role combinations many people hold together. Grant them as one [access package](/docs/guides/privileged-access/access-packages) instead of binding each role separately. `minIdentities` (default 3) is how many people must share a pattern, and `minRoles` (default 2) is the smallest combination reported as a bundle. `limit` (default 50) caps the list, while `summary` counts every suggestion by kind. Each suggestion names the roles, people, and group involved, the bindings it would remove, a `savings` estimate, and whether `apply` can carry it out. ```ts title="Signature" iam.api.roleMining.suggest( credential: CredentialInput, input: { tenantId: string; minIdentities?: number; minRoles?: number; kinds?: RoleSuggestionKind[]; limit?: number; }, ): Promise ``` ## usage [#usage] Returns the recorded access usage of the tenant: per person and action, when it was first and last allowed and how often. **HTTP:** `POST /api/iam/roleMining/usage` (requires a credential) · **Browser client:** `client.roleMining.usage()` * **Permission:** `iam:analysis:read` on `iam/analysis/*`. * **Audited as:** `iam:analysis:read`. * **Errors:** `INVALID_INPUT` when `limit` (1 to 1000) or `offset` is out of range. Records are sorted by last use, newest first; pass `identityId` to see one person's history. Buffered usage is written before the read, so the result is current. `tracking` tells you whether the deployment records usage at all, and `trackingSince` when this tenant's first use was recorded. Counts are approximate when several server instances record at once. ```ts title="Signature" iam.api.roleMining.usage( credential: CredentialInput, input: { tenantId: string; identityId?: string; limit?: number; offset?: number }, ): Promise<{ tracking: boolean; trackingSince: number | undefined; total: number; records: { identityId: string; action: string; firstUsedAt: number; lastUsedAt: number; count: number; }[]; }> ``` # roles (/docs/reference/api/roles) > Roles are named sets of permissions for a job function, such as Editor or Approver, that bindings give to people and groups. Roles are named sets of permissions for a job function, such as Editor or Approver, that bindings give to people and groups. You define what an editor may do once, [bind](/docs/reference/api/bindings#create) the role to everyone who edits, and change it in one place when the job changes: every holder sees the change at their next request. A role can build on other roles through inheritance, and it can also be taken on temporarily through a trust with `assume` instead of a binding. The guide is [roles and bindings](/docs/guides/authorization/roles). ## How a role grants [#how-a-role-grants] A role grants the union of three sources: * **Its own permissions.** Either a `permissions` list, which becomes one inline allow statement (`RolePermissions`) over every resource of the tenant, or a full inline `document` when access depends on conditions or specific resources. You pass one or the other, not both. * **Attached policies.** `policyIds` names stored, versioned [policies](/docs/reference/api/policies) that several roles can share. * **Inherited roles.** `inherits` lists up to 20 roles whose grants this role includes, recursively. A role cannot inherit itself, form a cycle, or inherit a protected role. Every role also records the [grant authority](/docs/guides/authorization/roles#grant-authorities) it was created under. That authority's ceiling bounds everything the role grants, whoever binds it, and inherited grants are bounded by the inheriting role's ceilings as well as their own, so inheriting a broader role never widens a delegated administrator's reach. Only the holder of that authority, or root, may edit or delete the role. The protected Owner role, created with every tenant, cannot be updated, deleted, inherited, or bound through this API; ownership changes go through [`identities.setOwner`](/docs/reference/api/identities#setowner). | Method | What it does | Access | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------- | | [`assume`](#assume) | Exchanges your session for a short-lived role session in a target tenant, through a trust the platform root created. | Credential | | [`create`](#create) | Creates a role from a permissions list, an inline policy document, attached policies, inherited roles, or a mix of them. | Credential | | [`delete`](#delete) | Deletes a role together with its bindings and their activations. | Credential | | [`get`](#get) | Returns one role by id, with its attached policies, inline document, and inherited roles. | Credential | | [`list`](#list) | Lists every role in the tenant, including the protected Owner role. | Credential | | [`listBindings`](#listbindings) | Lists who holds a role: its bindings, each with a summary of the person, service account, or group it names. | Credential | | [`listSessions`](#listsessions) | Lists the live role sessions in the tenant, of one role or trust when given, newest first. | Credential | | [`revokeSessions`](#revokesessions) | Ends every role session of a role issued before a point in time, whichever trust or provider issued it. | Credential | | [`update`](#update) | Changes a role's name, description, permissions or inline document, attached policies, or inherited roles. | Credential | ## assume [#assume] Exchanges your session for a short-lived role session in a target tenant, through a trust the platform root created. **HTTP:** `POST /api/iam/roles/assume` (requires a credential) · **Browser client:** `client.roles.assume()` * **Permission:** `iam:roles:assume` on the target role (`iam/{roleId}`), evaluated in your own tenant, and a trust that names you as its source identity. * **Audited as:** `iam:roles:assume` in your own tenant, and `role:assumed` in the target tenant (with the trust, your tenant, the duration, the credential format, and the names of any session tags), so both sides can see it. * **Errors:** `ROLE_CHAINING_DISABLED` when you call it from a role session; `IMPERSONATION_RESTRICTED` from a "view as" session; `NOT_FOUND` when the trust is not in the target tenant; `ACCESS_DENIED` when the trust is revoked, names another source identity, requires MFA your session lacks, expects an external ID you did not match, or does not admit the session tags or source identity you passed; `TENANT_INACTIVE` when the target tenant or an ancestor is not active; `INVALID_INPUT` for a `durationSeconds` outside the allowed range or a malformed session name, source identity, tag, or audience; `FEATURE_DISABLED` for `format: 'jwt'` when the deployment has no `sts.jwt` signing keys; `INVALID_POLICY` or `INVALID_ACTION` for an invalid session `policy`. Use it for cross-tenant support or automation, or for one task that needs a role nobody should hold permanently. The platform root sets up each trust with [`trust.create`](/docs/reference/api/trust#create): exactly one source identity, one target role, and optionally MFA, an external ID, and a ceiling. The returned `token` is a credential for the target tenant whose permissions are exactly the role's, bounded by the trust's ceiling and by the optional session `policy`, which can only narrow them. Your own roles do not carry over, and neither do the limits of the credential you called with: an API key's scopes or a session token's `policy` only decide whether you may assume the role (checked again on every use), not what the role session may do. Your MFA state and sign-in time do carry over, and the address you called from is recorded so the target tenant's IP allowlist and network blocks apply to the role session too. The session lasts `durationSeconds`: 900 by default, at most the smaller of the deployment's `sts.maxRoleSessionSeconds` (3600 unless configured) and the trust's own limit, and never longer than the session you called from. A value outside that range is refused rather than shortened. A role session cannot assume another role. Optional inputs describe the session for policies and the audit log: `sessionName` (who or what is acting, such as a ticket number), `sourceIdentity` (the person behind an automated caller), and `tags` (key/value pairs that policies read as `principal.sessionTags.{key}`). A trust admits no tags and forbids a source identity unless it is configured to allow them, because both can satisfy policy conditions. `format: 'jwt'` issues the credential as a signed JWT for `audience` instead of an opaque token. ```ts const { token, session } = await iam.api.roles.assume(credential, { tenantId: customerTenantId, trustId, durationSeconds: 900, policy: { version: 1, statements: [{ effect: 'allow', actions: ['documents:read'], resources: ['*'] }], }, }); // Call the API as { token } in customerTenantId until session.expiresAt. ``` ```ts title="Signature" iam.api.roles.assume( credential: CredentialInput, input: AssumeRoleInput, ): Promise ``` ## create [#create] Creates a role from a permissions list, an inline policy document, attached policies, inherited roles, or a mix of them. **HTTP:** `POST /api/iam/roles/create` (requires a credential) · **Browser client:** `client.roles.create()` * **Permission:** `iam:roles:create` on the tenant, plus an active grant authority. * **Audited as:** `iam:roles:create`. * **Errors:** `INVALID_INPUT` when both `permissions` and `document` are given, `permissions` is empty, more than 20 roles are inherited, or the name or description (at most 512 characters) is invalid; `INVALID_POLICY`, `INVALID_ACTION`, or `INVALID_RESOURCE_TYPE` when the permissions or document do not validate against the catalog; `NOT_FOUND` when an attached policy or inherited role is not in this tenant; `PROTECTED_RESOURCE` when inheriting a protected role; `GRANT_AUTHORITY_REQUIRED` when you hold no active grant authority; `LIMIT_EXCEEDED` when the tenant's plan limit for roles is reached. A new role grants nothing until it is bound. Create one role per job function in your product rather than one per person. ```ts const editor = await iam.api.roles.create(credential, { tenantId, name: 'Editor', description: 'Reads and writes documents', permissions: ['documents:read', 'documents:write'], }); // A manager does everything an editor does, plus exports. const manager = await iam.api.roles.create(credential, { tenantId, name: 'Manager', permissions: ['reports:export'], inherits: [editor.id], }); ``` ```ts title="Signature" iam.api.roles.create( credential: CredentialInput, input: RoleInput, ): Promise ``` ## delete [#delete] Deletes a role together with its bindings and their activations. **HTTP:** `POST /api/iam/roles/delete` (requires a credential) · **Browser client:** `client.roles.delete()` * **Permission:** `iam:roles:delete` on the role, and the grant authority the role was created under (or root). * **Audited as:** `iam:roles:delete`. * **Errors:** `RESOURCE_IN_USE` (409) while another role inherits it or an [access package](/docs/guides/privileged-access/access-packages) includes it; `PROTECTED_RESOURCE` for a protected role; `ACCESS_DENIED` when another administrator's authority created the role; `NOT_FOUND`; `INVARIANT_VIOLATION`. Everyone who held the role loses it at once. The in-use checks exist so that deleting a role never silently changes what another role or a package grants: change those first. ```ts title="Signature" iam.api.roles.delete( credential: CredentialInput, input: { tenantId: string; roleId: string }, ): Promise<{ deleted: boolean }> ``` ## get [#get] Returns one role by id, with its attached policies, inline document, and inherited roles. **HTTP:** `POST /api/iam/roles/get` (requires a credential) · **Browser client:** `client.roles.get()` * **Permission:** `iam:roles:read` on the role. * **Audited as:** `iam:roles:read`. * **Errors:** `NOT_FOUND` when the role is not in this tenant. ```ts title="Signature" iam.api.roles.get( credential: CredentialInput, input: { tenantId: string; roleId: string }, ): Promise ``` ## list [#list] Lists every role in the tenant, including the protected Owner role. **HTTP:** `POST /api/iam/roles/list` (requires a credential) · **Browser client:** `client.roles.list()` * **Permission:** `iam:roles:read` on the tenant. * **Audited as:** `iam:roles:read`. ```ts title="Signature" iam.api.roles.list( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listBindings [#listbindings] Lists who holds a role: its bindings, each with a summary of the person, service account, or group it names. **HTTP:** `POST /api/iam/roles/listBindings` (requires a credential) · **Browser client:** `client.roles.listBindings()` * **Permission:** `iam:bindings:read` on the role. * **Audited as:** `iam:bindings:read`. * **Errors:** `NOT_FOUND` when the role is not in this tenant. Expired bindings are left out. Future-dated and eligible bindings are included, so check `startsAt` and `eligible` to tell who holds the role right now. Each entry has `subject` (id, name, email, and kind for an identity; id and name for a group). Group bindings are listed as the group, not expanded to members, and holders of roles that inherit this one are not included. ```ts title="Signature" iam.api.roles.listBindings( credential: CredentialInput, input: { tenantId: string; roleId: string }, ): Promise<(Binding & { subject?: BindingSubject })[]> ``` ## listSessions [#listsessions] Lists the live role sessions in the tenant, of one role or trust when given, newest first. **HTTP:** `POST /api/iam/roles/listSessions` (requires a credential) · **Browser client:** `client.roles.listSessions()` * **Permission:** `iam:trust:read` on the role (`iam/{roleId}`), or on the tenant without `roleId`. * **Audited as:** `iam:trust:read`. * **Errors:** `NOT_FOUND` when `roleId` is not a role of this tenant; `INVALID_INPUT` for a `limit` outside 1 to 500. Each entry is a summary built for the target tenant's administrators: the session id, role, trust, the identity it acts as, the source tenant, session name, source identity, the web identity's provider and subject, MFA, format, creation and expiry times, and the recorded client address. Tokens, hashes, policies, and authority ids are never included. Expired sessions, sessions under a revoked trust, and sessions already below a revocation watermark are left out. `limit` defaults to 100. Use it to see who is currently working in the tenant through a trust before revoking with [`revokeSessions`](#revokesessions). ```ts title="Signature" iam.api.roles.listSessions( credential: CredentialInput, input: { tenantId: string; roleId?: string; trustId?: string; limit?: number }, ): Promise ``` ## revokeSessions [#revokesessions] Ends every role session of a role issued before a point in time, whichever trust or provider issued it. **HTTP:** `POST /api/iam/roles/revokeSessions` (requires a credential) · **Browser client:** `client.roles.revokeSessions()` * **Permission:** `iam:roles:revoke-sessions` on the role, with recent authentication. * **Audited as:** `iam:roles:revoke-sessions` and `role:sessions-revoked` (with the watermark and the number of sessions deleted). * **Errors:** `INVALID_INPUT` when `before` is not a whole number of milliseconds, is negative, or lies in the future; `NOT_FOUND` when the role is not in this tenant; `RECENT_AUTH_REQUIRED`; `ACCESS_DENIED`. This is the "revoke older sessions" lever for an incident: `before` defaults to now, which ends every role session issued so far, while new assumptions keep working. The role's `sessionsRevokedBefore` watermark only moves forward and is kept by [`update`](#update) and configuration sync, so a session created before it is refused at its next use (with `UNAUTHENTICATED`), and matching rows are deleted at once. It only removes access, so it can be delegated to the target tenant's administrators. Session JWTs that other services verify offline stay valid there until they expire. To end the sessions of one trust or one OIDC provider instead, use [`trust.revokeSessions`](/docs/reference/api/trust#revokesessions) or [`oidcProviders.revokeSessions`](/docs/reference/api/oidc-providers#revokesessions). ```ts const { revoked, sessionsRevokedBefore } = await iam.api.roles.revokeSessions(credential, { tenantId, roleId }); ``` ```ts title="Signature" iam.api.roles.revokeSessions( credential: CredentialInput, input: { tenantId: string; roleId: string; before?: number }, ): Promise<{ roleId: string; sessionsRevokedBefore: number; revoked: number }> ``` ## update [#update] Changes a role's name, description, permissions or inline document, attached policies, or inherited roles. **HTTP:** `POST /api/iam/roles/update` (requires a credential) · **Browser client:** `client.roles.update()` * **Permission:** `iam:roles:update` on the role, and the grant authority the role was created under (or root). * **Audited as:** `iam:roles:update`. * **Errors:** `PROTECTED_RESOURCE` for a protected role, or when inheriting one; `ACCESS_DENIED` when another administrator's authority created the role; `GRANT_AUTHORITY_REQUIRED` when you hold no active grant authority; `INVALID_INPUT` when both `permissions` and `document` are given or the inheritance would form a cycle; `INVALID_POLICY`, `INVALID_ACTION`, or `INVALID_RESOURCE_TYPE`; `NOT_FOUND`; `INVARIANT_VIOLATION` when the change would newly break an enforced [access invariant](/docs/reference/api/invariants). Only the fields you pass change. `permissions` replaces the inline document with a new permissions statement, `document: null` removes the inline document, `policyIds` replaces the attached set, and `inherits: []` clears inheritance. Everyone who holds the role, directly, through a group, or through a role that inherits it, sees the change at their next request, so preview it first with [`impact.preview`](/docs/reference/api/impact#preview). ```ts await iam.api.roles.update(credential, { tenantId, roleId: editor.id, permissions: ['documents:read', 'documents:write', 'documents:share'], }); ``` ```ts title="Signature" iam.api.roles.update( credential: CredentialInput, input: RoleUpdate, ): Promise ``` # root (/docs/reference/api/root) > Root administrators run the platform itself: they create organizations, set plan limits, and help customers who are locked out. Root administrators run the platform itself: they create organizations, set plan limits, and help customers who are locked out. Their authority reaches every tenant, so it is a protected capability that only existing root administrators can grant or remove, and this group is where they do it. See [root administration](/docs/guides/concepts/tenants-and-identities#root-administration). ## What root authority is [#what-root-authority-is] Root authority is a protected flag (`rootAdmin`) on a human identity of the root tenant. It takes effect only in a user session with MFA, and it is checked against current storage on every use. A role named `root-admin`, a matching email, a linked account, or a token claim can never confer it. The first root administrator is created by [`iam.bootstrap()`](/docs/reference/api#bootstrap), and [`iam.recoverRoot()`](/docs/reference/api#recoverroot) lets the deployment operator create a new one when nobody can sign in as root any more; neither is an HTTP endpoint. Everyone after the first is added with [`setAdministrator`](#setadministrator). The last active root administrator is always protected, so the platform cannot be left without one. | Method | What it does | Access | | ------------------------------------------- | ------------------------------------------------------------------------- | ---------- | | [`listAdministrators`](#listadministrators) | Lists the identities that hold the root capability. | Credential | | [`setAdministrator`](#setadministrator) | Grants the root capability to a person in the root tenant, or removes it. | Credential | ## listAdministrators [#listadministrators] Lists the identities that hold the root capability. **HTTP:** `POST /api/iam/root/listAdministrators` (requires a credential) · **Browser client:** `client.root.listAdministrators()` * **Permission:** `iam:identities:read` on the root tenant; root administrators only. * **Audited as:** `iam:identities:read`. * **Errors:** `ACCESS_DENIED` for anyone but a root administrator in an MFA session; `INVALID_INPUT` when `tenantId` is not the root tenant. Use it to review who holds platform-wide authority, for example in a quarterly access review. Identities are returned without credential material. ```ts title="Signature" iam.api.root.listAdministrators( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## setAdministrator [#setadministrator] Grants the root capability to a person in the root tenant, or removes it. **HTTP:** `POST /api/iam/root/setAdministrator` (requires a credential) · **Browser client:** `client.root.setAdministrator()` * **Permission:** `iam:root:grant` on the identity; root administrators only, with recent authentication. * **Audited as:** `iam:root:grant`. * **Errors:** `ACCESS_DENIED` for anyone but a root administrator in an MFA session; `RECENT_AUTH_REQUIRED` without recent authentication; `INVALID_INPUT` when `tenantId` is not the root tenant, `enabled` is not a boolean, or the identity is a service account; `NOT_FOUND` when the identity is not in the root tenant; `LAST_ROOT_ADMIN` when removing the last active root administrator; `LAST_OWNER` when removing the capability from someone who is also the root tenant's last active owner; `INVARIANT_VIOLATION` when the change would newly break an enforced access invariant. Only human identities (`kind: 'user'`) of the root tenant qualify. Every session the identity holds, including role sessions it assumed, is revoked in the same transaction, whether the capability is granted or removed. The person signs in again (with MFA) and gets a session that reflects the change, so no session keeps authority it was not issued with. ```ts await iam.api.root.setAdministrator(rootCredential, { tenantId: rootTenantId, identityId, enabled: true }); ``` ```ts title="Signature" iam.api.root.setAdministrator( credential: CredentialInput, input: { tenantId: string; identityId: string; enabled: boolean }, ): Promise ``` # security (/docs/reference/api/security) > Network blocks shut out an IP address or range during an incident, for one tenant or, set by root administrators, for the whole installation. Network blocks shut out an IP address or range during an incident, for one tenant or, set by root administrators, for the whole installation. When a credential-stuffing attack or a compromised network shows up in the sign-in failures, you need to stop it now, without touching each account. A block is the incident-response counterpart of a tenant's `allowedIpRanges` allowlist. See [network blocks](/docs/operations/security#network-blocks-and-ip-bound-sessions). ## How network blocks work [#how-network-blocks-work] A live block refuses every sign-in and authentication flow, every existing session whose recorded client IP falls in the blocked network, and every API key or assumed-role token presented from it, with `IP_BLOCKED`. Blocks are checked before rate limits and credentials, so traffic from a blocked address cannot count against anyone's rate limits. * **Scope.** An organization's blocks apply to that tenant. Root administrators can set a `platform` block on the root tenant, which applies to every tenant. * **Networks.** A single IPv4 or IPv6 address, or a CIDR block such as `198.51.100.0/24`. * **Lifetime.** With `durationMs` (one minute to one year) a block lapses by itself; without it, it stays until [`unblockNetwork`](#unblocknetwork) lifts it. Lapsed blocks are deleted later by the retention worker (`purgeDeleted`). * **Addresses.** Blocks only work when the deployment records client IPs (`http.clientInfo`); a request without a known IP is never blocked. * **Propagation.** A change applies at once in the server process that made it; other processes pick it up within seconds. Every change is audited twice: once as the `iam:security:manage` operation and once as `security:network-block` or `security:network-unblock`, with the network and scope in the metadata. Subscribe a webhook to `security:*` to alert on them. | Method | What it does | Access | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------- | | [`blockNetwork`](#blocknetwork) | Blocks an IP address or CIDR range for the tenant, or for the whole platform, optionally for a limited time. | Credential | | [`listBlocks`](#listblocks) | Lists the tenant's network blocks, newest first, each with `active` telling whether it still applies. | Credential | | [`unblockNetwork`](#unblocknetwork) | Lifts a network block before it lapses. | Credential | ## blockNetwork [#blocknetwork] Blocks an IP address or CIDR range for the tenant, or for the whole platform, optionally for a limited time. **HTTP:** `POST /api/iam/security/blockNetwork` (requires a credential) · **Browser client:** `client.security.blockNetwork()` * **Permission:** `iam:security:manage` on `iam/security/networks`, with recent authentication. A `platform` block also requires a root administrator acting on the root tenant. * **Audited as:** `iam:security:manage` and `security:network-block` (metadata: `network`, `reason`, `platform`, `expiresAt`, and `renewed`). * **Errors:** `INVALID_INPUT` when `network` is not an IPv4 or IPv6 address or CIDR block, `reason` is empty, the duration is out of range, or the network includes your own address; `ACCESS_DENIED` for a platform block from anyone but a root administrator on the root tenant; `RECENT_AUTH_REQUIRED` without recent authentication; `IMPERSONATION_RESTRICTED` from a "view as" session. A block that would cover your own address, either the one your session was issued from or the one this request comes from, is refused, so you cannot lock yourself out of the session you need to lift it. Blocking a network that is already blocked in the same scope renews that block: the reason, the expiry, and who set it are replaced, and the audit metadata says `renewed: true`. ```ts await iam.api.security.blockNetwork(credential, { tenantId, network: '203.0.113.0/24', reason: 'Credential stuffing, incident 4211', durationMs: 24 * 60 * 60 * 1000, }); ``` ```ts title="Signature" iam.api.security.blockNetwork( credential: CredentialInput, input: { tenantId: string; network: string; reason: string; durationMs?: number; platform?: boolean; }, ): Promise<{ active: boolean; network: string; reason: string; createdAt: number; createdBy: string; expiresAt?: number; platform?: boolean; id: string; tenantId: string; uniqueKey?: string; }> ``` ## listBlocks [#listblocks] Lists the tenant's network blocks, newest first, each with `active` telling whether it still applies. **HTTP:** `POST /api/iam/security/listBlocks` (requires a credential) · **Browser client:** `client.security.listBlocks()` * **Permission:** `iam:security:read` on `iam/security/networks`. * **Audited as:** `iam:security:read`. Lapsed blocks stay in the list with `active: false` until the retention worker deletes them. Platform blocks are listed on the root tenant, where they were set. ```ts title="Signature" iam.api.security.listBlocks( credential: CredentialInput, input: { tenantId: string }, ): Promise<{ active: boolean; network: string; reason: string; createdAt: number; createdBy: string; expiresAt?: number; platform?: boolean; id: string; tenantId: string; uniqueKey?: string; }[]> ``` ## unblockNetwork [#unblocknetwork] Lifts a network block before it lapses. **HTTP:** `POST /api/iam/security/unblockNetwork` (requires a credential) · **Browser client:** `client.security.unblockNetwork()` * **Permission:** `iam:security:manage` on `iam/security/networks`, with recent authentication. Lifting a platform block also requires a root administrator acting on the root tenant. * **Audited as:** `iam:security:manage` and `security:network-unblock` (metadata: `network`, `platform`). * **Errors:** `NOT_FOUND` when the block is not in this tenant; `ACCESS_DENIED` for a platform block from anyone but a root administrator; `RECENT_AUTH_REQUIRED` without recent authentication. The block is deleted, so traffic from the network is accepted again right away (within seconds on other server processes). ```ts title="Signature" iam.api.security.unblockNetwork( credential: CredentialInput, input: { tenantId: string; blockId: string }, ): Promise<{ success: true }> ``` # serviceAccounts (/docs/reference/api/service-accounts) > Service accounts are identities for machines: a deploy pipeline, a billing worker, a partner integration. Service accounts are identities for machines: a deploy pipeline, a billing worker, a partner integration. They live in a tenant's directory next to people (`kind: 'service'`), receive access the same way through role bindings, groups, and relationships, and authenticate only with [API keys](/docs/reference/api/credentials), never by signing in. Giving each integration its own account keeps its access reviewable and revocable without touching anyone's personal access. ## How service accounts relate to identities [#how-service-accounts-relate-to-identities] This group is a focused view of the identity directory: it only ever returns or changes identities of kind `service`, and it is authorized with the same `iam:identities:*` actions as [`identities`](/docs/reference/api/identities), checked on the tenant for `create` and `list` and on `iam/{identityId}` for everything else. When an account calls the API with a key, policies see it as `principal.kind: 'service'`, so a statement can treat machines differently from people. Bind roles to an account with [`bindings.create`](/docs/reference/api/bindings#create), and remove one completely with [`identities.offboard`](/docs/reference/api/identities#offboard) when you need a successor for the resources it owns. ## Scheduled deactivation [#scheduled-deactivation] An `expiresAt` (epoch milliseconds, at most ten years ahead) gives an account a deadline, which suits a vendor integration or a migration job. From that instant every key of the account is refused. The purge worker (`iam.purgeDeleted()`, see [scheduled jobs](/docs/operations/jobs)) then disables the account, deletes its keys, and records `identity:expire`. If you extend or clear the deadline before the worker runs, the existing keys work again; after it has run, extend or clear the deadline, enable the account with [`setStatus`](#setstatus), and issue new keys. See [time-bound identities](/docs/guides/privileged-access/lifecycle#time-bound-identities). | Method | What it does | Access | | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------- | | [`create`](#create) | Creates a service account in the tenant, optionally with a date after which it is deactivated. | Credential | | [`delete`](#delete) | Deletes a service account, revoking its keys and removing every grant it held. | Credential | | [`get`](#get) | Returns one service account by id. | Credential | | [`list`](#list) | Lists the tenant's service accounts, without deleted ones unless you ask for them. | Credential | | [`setStatus`](#setstatus) | Disables a service account, ending all its access at once, or enables it again. | Credential | | [`update`](#update) | Renames a service account, changes its description or directory attributes, or schedules or clears its deactivation. | Credential | ## create [#create] Creates a service account in the tenant, optionally with a date after which it is deactivated. **HTTP:** `POST /api/iam/serviceAccounts/create` (requires a credential) · **Browser client:** `client.serviceAccounts.create()` * **Permission:** `iam:identities:create` on the tenant, and the caller must hold an active grant authority. * **Audited as:** `iam:identities:create`. * **Errors:** `GRANT_AUTHORITY_REQUIRED` when the caller holds no grant authority; `LIMIT_EXCEEDED` at the tenant's service account limit; `INVALID_INPUT` for an empty name, a description over 512 characters, or an `expiresAt` that is not in the future or is more than ten years away. The new account is active but holds no access until you bind roles to it or add it to groups. Automatic [access package](/docs/guides/privileged-access/access-packages) rules are evaluated for it right after creation, so a rule that matches service accounts grants its package at once. Then issue a key with [`credentials.create`](/docs/reference/api/credentials#create). ```ts const account = await iam.api.serviceAccounts.create(credential, { tenantId, name: 'Billing sync', description: 'Nightly export to the finance system', expiresAt: Date.parse('2027-06-30T00:00:00Z'), }); ``` ```ts title="Signature" iam.api.serviceAccounts.create( credential: CredentialInput, input: { tenantId: string; name: string; description?: string; expiresAt?: number }, ): Promise ``` ## delete [#delete] Deletes a service account, revoking its keys and removing every grant it held. **HTTP:** `POST /api/iam/serviceAccounts/delete` (requires a credential) · **Browser client:** `client.serviceAccounts.delete()` * **Permission:** `iam:identities:delete` on the account, with recent authentication. * **Audited as:** `iam:identities:delete`, plus `identity:delete` with `metadata.kind` set to `service`. * **Errors:** `NOT_FOUND` when the id is not a service account of this tenant; `CONFLICT` when it is already deleted; `INVALID_INPUT` when an account tries to delete itself; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`. In one transaction the account's keys and assumed-role sessions end, its role bindings, group memberships, activations, package assignments, relationships, and boundary are removed, pending access requests are cancelled, and any grant authority it held is revoked. The account stays as a deleted record, visible with `list({ includeDeleted })`, so the audit log keeps resolving its id. ```ts title="Signature" iam.api.serviceAccounts.delete( credential: CredentialInput, input: { tenantId: string; identityId: string }, ): Promise ``` ## get [#get] Returns one service account by id. **HTTP:** `POST /api/iam/serviceAccounts/get` (requires a credential) · **Browser client:** `client.serviceAccounts.get()` * **Permission:** `iam:identities:read` on the account. * **Audited as:** `iam:identities:read`. * **Errors:** `NOT_FOUND` when the id is not a service account of this tenant (people are not returned here). A deleted account is still returned, with `status: 'deleted'`. ```ts title="Signature" iam.api.serviceAccounts.get( credential: CredentialInput, input: { tenantId: string; identityId: string }, ): Promise ``` ## list [#list] Lists the tenant's service accounts, without deleted ones unless you ask for them. **HTTP:** `POST /api/iam/serviceAccounts/list` (requires a credential) · **Browser client:** `client.serviceAccounts.list()` * **Permission:** `iam:identities:read` on the tenant. * **Audited as:** `iam:identities:read`. Pass `includeDeleted: true` to include deleted accounts, for example when resolving old audit entries. Combine it with [`credentials.list`](/docs/reference/api/credentials#list) to review which accounts hold keys and when they were last used. ```ts title="Signature" iam.api.serviceAccounts.list( credential: CredentialInput, input: { tenantId: string; includeDeleted?: boolean }, ): Promise ``` ## setStatus [#setstatus] Disables a service account, ending all its access at once, or enables it again. **HTTP:** `POST /api/iam/serviceAccounts/setStatus` (requires a credential) · **Browser client:** `client.serviceAccounts.setStatus()` * **Permission:** `iam:identities:update` on the account, with recent authentication. * **Audited as:** `iam:identities:update`. * **Errors:** `INVALID_TRANSITION` (409) when enabling an account whose `expiresAt` has passed; `NOT_FOUND` when the id is not a service account of this tenant or was deleted; `INVALID_INPUT` for a status other than `active` or `disabled`; `INVARIANT_VIOLATION` when an enforced [access invariant](/docs/guides/governance/change-safety) would newly fail; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`. Disabling deletes every API key the account holds and every role session it assumed, while its bindings and memberships stay in place. Use it to contain a leaked key or pause an integration. Enabling the account again does not bring the keys back: issue new ones. To re-enable an expired account, first extend or clear `expiresAt` with [`update`](#update), so nobody quietly turns a finished integration back on. ```ts title="Signature" iam.api.serviceAccounts.setStatus( credential: CredentialInput, input: { tenantId: string; identityId: string; status: 'active' | 'disabled' }, ): Promise ``` ## update [#update] Renames a service account, changes its description or directory attributes, or schedules or clears its deactivation. **HTTP:** `POST /api/iam/serviceAccounts/update` (requires a credential) · **Browser client:** `client.serviceAccounts.update()` * **Permission:** `iam:identities:update` on the account. * **Audited as:** `iam:identities:update`. * **Errors:** `INVALID_INPUT` when nothing is given to change, an attribute is not declared in `permissions.identityAttributes` or has the wrong type, or `expiresAt` is invalid; `NOT_FOUND` when the id is not a service account of this tenant or was deleted; `INVARIANT_VIOLATION` when an enforced access invariant would newly fail. `attributes` replaces the account's whole attribute set; policies read them as `principal.{name}`. Pass `expiresAt: null` to make the account permanent. Automatic access package rules are re-evaluated for the account afterwards, so an attribute change can add or remove package access. ```ts title="Signature" iam.api.serviceAccounts.update( credential: CredentialInput, input: { tenantId: string; identityId: string; name?: string; description?: string; attributes?: Record; expiresAt?: number | null; }, ): Promise ``` # sod (/docs/reference/api/sod) > Separation-of-duties rules name roles that nobody may hold together, such as creating suppliers and approving payments to them. Separation-of-duties rules name roles that nobody may hold together, such as creating suppliers and approving payments to them. Each role is fine on its own; the combination makes fraud or an unnoticed mistake possible. Because roles reach people through many paths over time (direct bindings, groups, access requests, packages, configuration), nobody sees such a combination forming. A rule makes Better IAM check for it on every operation that can grant a role, and report the conflicts that already exist. The guide is [separation of duties](/docs/guides/authorization/separation-of-duties). ## How rules are checked [#how-rules-are-checked] A person holds a rule's role when they have a direct binding to it, a binding through a group they are a live member of, an eligible (just-in-time) binding even before activating it, or a future-dated binding. Expired bindings, lapsed memberships, and deleted identities do not count. Role inheritance is not expanded: name the roles you actually bind. A rule has one of two modes: * **`prevent`** (the default): every operation that can grant a role (`bindings.create` and `bindings.update`, group membership changes, `identities.createMany`, access-request approval, access-package assignment and approved package requests, `config.apply`, and member-invitation acceptance) compares the tenant's conflicts before and after it runs. If it would create a new one, it fails with `SOD_CONFLICT` (409) and its transaction rolls back. * **`detect`**: nothing is refused; conflicts are only reported by `violations` and by the access analysis, as high-severity `separation-of-duties` findings. Conflicts that already existed never block unrelated work, so you can add a rule to a tenant that is not clean yet and fix violations at your own pace. SCIM role mappings are not blocked; their conflicts appear in the reports. Rule management is authorized on `iam/sod/*` (create, list, violations) and `iam/sod/{ruleId}` (update, delete), so a compliance team can manage rules without other administrative rights. | Method | What it does | Access | | --------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------- | | [`create`](#create) | Declares 2 to 20 roles that nobody may hold together, and reports how many people already hold two of them. | Credential | | [`delete`](#delete) | Removes a rule, so the combination is no longer checked or reported. | Credential | | [`list`](#list) | Lists the tenant's rules, newest first. | Credential | | [`update`](#update) | Changes a rule's name, description, roles, or mode. | Credential | | [`violations`](#violations) | Lists everyone who currently holds two or more roles of a rule, with names for review screens. | Credential | ## create [#create] Declares 2 to 20 roles that nobody may hold together, and reports how many people already hold two of them. **HTTP:** `POST /api/iam/sod/create` (requires a credential) · **Browser client:** `client.sod.create()` * **Permission:** `iam:sod:manage` on `iam/sod/*`. * **Audited as:** `iam:sod:manage`. * **Errors:** `INVALID_INPUT` when `name` or `roleIds` is missing, fewer than 2 or more than 20 distinct roles are named, one of them is a protected Owner role, the `mode` is not `prevent` or `detect`, or the name (200 characters) or description (1000) is too long; `NOT_FOUND` when a role is not in this tenant. The result is the stored rule plus `existingViolations`, the number of conflicts that already exist. Creating a rule never fails because of them. To measure a rule's impact before enforcing it, create it with `mode: 'detect'` and switch to `prevent` with `update` later. ```ts const rule = await iam.api.sod.create(credential, { tenantId, name: 'Supplier creation vs payment approval', roleIds: [supplierAdmin.id, paymentApprover.id], description: 'Finance controls policy, section 4.2', }); // rule.existingViolations: people who already hold both roles ``` ```ts title="Signature" iam.api.sod.create( credential: CredentialInput, input: { tenantId: string; name: string; roleIds: string[]; mode?: SodRule['mode']; description?: string; }, ): Promise<{ existingViolations: number; name: string; description?: string; roleIds: string[]; mode: 'prevent' | 'detect'; createdAt: number; createdBy: string; id: string; tenantId: string; uniqueKey?: string; }> ``` ## delete [#delete] Removes a rule, so the combination is no longer checked or reported. **HTTP:** `POST /api/iam/sod/delete` (requires a credential) · **Browser client:** `client.sod.delete()` * **Permission:** `iam:sod:manage` on `iam/sod/{ruleId}`. * **Audited as:** `iam:sod:manage`. * **Errors:** `NOT_FOUND` when the rule is not in this tenant. ```ts title="Signature" iam.api.sod.delete( credential: CredentialInput, input: { tenantId: string; ruleId: string }, ): Promise<{ deleted: boolean }> ``` ## list [#list] Lists the tenant's rules, newest first. **HTTP:** `POST /api/iam/sod/list` (requires a credential) · **Browser client:** `client.sod.list()` * **Permission:** `iam:sod:read` on `iam/sod/*`. * **Audited as:** `iam:sod:read`. ```ts title="Signature" iam.api.sod.list( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## update [#update] Changes a rule's name, description, roles, or mode. **HTTP:** `POST /api/iam/sod/update` (requires a credential) · **Browser client:** `client.sod.update()` * **Permission:** `iam:sod:manage` on `iam/sod/{ruleId}`. * **Audited as:** `iam:sod:manage`. * **Errors:** `NOT_FOUND` when the rule is not in this tenant; `INVALID_INPUT` under the same rules as `create`. Only the fields you pass change; `roleIds` replaces the whole list. Switching to `prevent` takes effect for the next granting operation, and conflicts that exist at that moment still do not block unrelated work. ```ts title="Signature" iam.api.sod.update( credential: CredentialInput, input: { tenantId: string; ruleId: string; name?: string; description?: string; roleIds?: string[]; mode?: SodRule['mode']; }, ): Promise ``` ## violations [#violations] Lists everyone who currently holds two or more roles of a rule, with names for review screens. **HTTP:** `POST /api/iam/sod/violations` (requires a credential) · **Browser client:** `client.sod.violations()` * **Permission:** `iam:sod:read` on `iam/sod/*`. * **Audited as:** `iam:sod:read`. Both `prevent` and `detect` rules are covered; pass `ruleId` to check one rule (an unknown id returns an empty list). Each entry names the rule (`ruleId`, `ruleName`, `mode`), the person (`identityId`, and `identityName`, their email or else their name), and the conflicting roles (`roleIds`, `roleNames`). Disabled identities are included, because they can be re-enabled. To fix a violation, remove one of the conflicting grants: delete a binding with [`bindings.delete`](/docs/reference/api/bindings#delete), remove the person from the group that carries the role, or revoke the access package that granted it. ```ts const violations = await iam.api.sod.violations(credential, { tenantId, ruleId: rule.id }); ``` ```ts title="Signature" iam.api.sod.violations( credential: CredentialInput, input: { tenantId: string; ruleId?: string }, ): Promise<{ identityName: string; roleNames: string[]; ruleId: string; ruleName: string; mode: SodRule['mode']; identityId: string; roleIds: string[]; }[]> ``` # sts (/docs/reference/api/sts) > The sts group issues and inspects temporary credentials, the way a cloud security token service does. The `sts` group issues and inspects temporary credentials, the way a cloud security token service does. A CLI or a CI job can trade a long-lived credential for a short-lived, narrowed one (`getSessionToken`), a workload can trade an external OpenID Connect token for a role session without holding any IAM secret (`assumeRoleWithWebIdentity`), and any holder can ask who a credential acts as (`getCallerIdentity`). Role sessions through a named trust are issued by [`roles.assume`](/docs/reference/api/roles#assume). ## Temporary credentials [#temporary-credentials] Every issuer returns the same shape: `{ token, tokenType: 'Bearer', format, expiresAt, expiresIn, audience?, session }`. The token appears once, in the response body; these routes never set a cookie, so the credential only ever travels as `Authorization: Bearer`. `session` is an allowlist summary (id, tenant, kind, identity, expiry, MFA, and the role, trust, session name, and source identity when they apply) that never carries hashes, policies, or authority ids. Opaque tokens are typed and checksummed: `biam_sts_…` for session tokens and `biam_rol_…` for role sessions, 58 characters, so secret scanners can find a leaked one. With `format: 'jwt'` the credential is instead a session JWT signed with the deployment's `sts.jwt` keys, which downstream services can verify offline against `GET {basePath}/.well-known/jwks.json` with `createSessionTokenVerifier` from `better-iam/session-tokens`. IAM itself still checks the stored session on every use, so revocation inside IAM is immediate; offline verifiers only see it when the token expires. Configure the ceilings, signing keys, and web-identity switches in [temporary credentials](/docs/operations/deployment/configuration#temporary-credentials). A temporary credential never outlives its source, never passes recent-authentication checks, is never treated as an owner or root administrator, and cannot accept agreements, activate eligible bindings, or make other self-service changes. Policies can tell temporary credentials apart with `principal.sessionKind` and the session keys (`principal.sessionName`, `principal.sessionTags.{key}`, `principal.webIdentitySubject`, …) described in [context keys](/docs/guides/authorization/conditions#context-keys). | Method | What it does | Access | | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`assumeRoleWithWebIdentity`](#assumerolewithwebidentity) | Exchanges a verified external OpenID Connect token, such as a GitHub Actions or Kubernetes token, for a role session under a web-identity trust. | Public | | [`getCallerIdentity`](#getcalleridentity) | Returns who the presented credential acts as: identity, tenant, session kind and id, format, MFA, expiry, and any role, trust, session name, tags, or web identity behind it. | Credential | | [`getSessionToken`](#getsessiontoken) | Mints a short-lived session token for your own identity from a signed-in session or an API key, optionally narrowed by a policy and attested with a fresh MFA code. | Credential | ## assumeRoleWithWebIdentity [#assumerolewithwebidentity] Exchanges a verified external OpenID Connect token, such as a GitHub Actions or Kubernetes token, for a role session under a web-identity trust. **HTTP:** `POST /api/iam/sts/assumeRoleWithWebIdentity` (no credential) · **Browser client:** `client.sts.assumeRoleWithWebIdentity()` * **Permission:** None: public. The external token is the only credential, and the trust's claim conditions decide whether it is admitted. * **Audited as:** `role:assumed-with-web-identity` in the trust's tenant (outcome `allow`, or `deny` with a `reason` once the trust has resolved). * **Errors:** `WEB_IDENTITY_REJECTED` (403) for every refusal that depends on stored state or on the token (unknown or revoked trust, disabled provider, bad signature, issuer, audience or lifetime, unmet conditions, replay, inactive service account, and so on), always with the same body; `FEATURE_DISABLED` when `sts.webIdentity.enabled` is off, or for `format: 'jwt'` without `sts.jwt`; `INVALID_INPUT` for a malformed request, `durationSeconds`, or `audience`; `RATE_LIMITED` once the trust's exchange budget (`sts.webIdentity.maxExchangesPerWindow`) is spent; `LIMIT_EXCEEDED` (409) when the trust already holds `sts.webIdentity.maxSessionsPerTrust` live sessions; `IP_BLOCKED` or `IP_NOT_ALLOWED` from the tenant's network rules; `ACCESS_DENIED` for a JWT audience the service account may not obtain. The session acts as the trust's service account in the trust's tenant, with the role's permissions bounded by the trust's ceiling, the optional scope-down `policy`, and the grant authorities of whoever created the trust and the provider. It is kind `role` with `session.webIdentity` set, never carries MFA, and ends when the provider is disabled, the trust is revoked, the service account is disabled, or either authority is revoked. `sessionName` is required and reaches policies as `principal.sessionName`; the verified subject reaches them as `principal.webIdentitySubject`. Because callers cannot tell why a token was refused, administrators debug with [`trust.evaluateWebIdentity`](/docs/reference/api/trust#evaluatewebidentity), which reports the reason and every failing condition. Each token can be redeemed once per provider unless the provider sets `replayProtection: 'off'`. ```bash # In a GitHub Actions job with `permissions: id-token: write`. ID_TOKEN=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://iam.example.com" | jq -r .value) curl -s https://iam.example.com/api/iam/sts/assumeRoleWithWebIdentity \ -H 'Content-Type: application/json' -H 'X-Better-IAM: 1' \ -d "{\"tenantId\":\"$TENANT_ID\",\"trustId\":\"$TRUST_ID\",\"webIdentityToken\":\"$ID_TOKEN\",\"sessionName\":\"deploy-$GITHUB_RUN_ID\"}" ``` ```ts title="Signature" iam.api.sts.assumeRoleWithWebIdentity( input: WebIdentityExchangeInput, ): Promise ``` ## getCallerIdentity [#getcalleridentity] Returns who the presented credential acts as: identity, tenant, session kind and id, format, MFA, expiry, and any role, trust, session name, tags, or web identity behind it. **HTTP:** `POST /api/iam/sts/getCallerIdentity` (requires a credential) · **Browser client:** `client.sts.getCallerIdentity()` * **Permission:** None beyond a valid credential. It works for every session kind. * **Audited as:** nothing; it records no event. * **Errors:** `UNAUTHENTICATED` when the credential is invalid, expired, or revoked. The credential is re-validated exactly as for any other call, so this doubles as the online revocation check for a service that holds a session JWT: a token that still verifies offline but was revoked in IAM fails here. The result is an allowlist projection and never includes hashes, policies, the source session, or authority ids. The CLI's `whoami` command prints it. ```ts const caller = await iam.api.sts.getCallerIdentity({ token }); if (caller.sessionKind === 'role') console.log(`acting as role ${caller.roleId} via trust ${caller.trustId}`); ``` ```ts title="Signature" iam.api.sts.getCallerIdentity( credential: CredentialInput, ): Promise ``` ## getSessionToken [#getsessiontoken] Mints a short-lived session token for your own identity from a signed-in session or an API key, optionally narrowed by a policy and attested with a fresh MFA code. **HTTP:** `POST /api/iam/sts/getSessionToken` (requires a credential) · **Browser client:** `client.sts.getSessionToken()` * **Permission:** `iam:session-tokens:create` on your own identity (`iam/{identityId}`) in your tenant. Owners hold it through the Owner role; anyone else needs an explicit grant. * **Audited as:** `iam:session-tokens:create` and `session-token:issued` (with the new session id, duration, format, and whether an MFA code was verified). * **Errors:** `CREDENTIAL_CHAINING_DISABLED` (400) from a role session or another session token; `IMPERSONATION_RESTRICTED` from a "view as" session; `MFA_NOT_ENROLLED` for `mfaCode` from an API key or a person without an authenticator; `INVALID_MFA` for a wrong or reused code; `RATE_LIMITED` after too many codes; `LIMIT_EXCEEDED` (409) when you already hold `sts.maxSessionTokensPerIdentity` live tokens (50 by default); `INVALID_INPUT` for a duration, session name, format, or audience out of bounds; `FEATURE_DISABLED` for `format: 'jwt'` without `sts.jwt`; `ACCESS_DENIED`. The token acts with your own grants, bounded by `policy` and by the source's own limits (an API key's scopes and authority carry over), and lasts `durationSeconds`: 3600 by default, at most `sts.maxSessionTokenSeconds` (12 hours unless configured), and never beyond the source's expiry. It ends when its source ends, and using it never refreshes the source's idle timer, so prefer an API key as the source for long-running automation. With `mfaCode` (a current authenticator code; people only), the token carries `mfa: true` and a fresh MFA time, so it can then assume roles through trusts that require MFA. This is the MFA-then-assume pattern for command-line tools: roles cannot take a code themselves. Without a code, the source's MFA state is copied; API keys never carry MFA. The token's `policy` and its source's scopes decide which roles it may assume, but they do not carry into the role session, which acts with the role's permissions within the trust's ceiling and its own session `policy`. ```ts // A CLI step-up: a one-hour, read-only token that can assume an MFA-gated role. const { token, expiresAt } = await iam.api.sts.getSessionToken( { token: signedInToken }, { mfaCode: '123456', sessionName: 'deploy-cli', policy: { version: 1, statements: [{ effect: 'allow', actions: ['iam:roles:assume', 'documents:read'], resources: ['*'] }], }, }, ); ``` ```ts title="Signature" iam.api.sts.getSessionToken( credential: CredentialInput, input?: GetSessionTokenInput | undefined, ): Promise ``` # teams (/docs/reference/api/teams) > Teams are the working units inside an organization: Platform, Site Reliability, the Payments squad. Teams are the working units inside an organization: Platform, Site Reliability, the Payments squad. A team can sit under another team, has maintainers who manage its membership themselves, can take join requests, and gives its members access through roles bound to its backing group. Departments, the reporting structure, are the [`departments`](/docs/reference/api/departments) group. ## How teams grant access [#how-teams-grant-access] Every team owns a backing group (`groupId`, named `team:{slug}`). Bind roles to it with [`bindings.create`](/docs/reference/api/bindings#create) (`subjectType: 'group'`). The backing group holds the live members of the team and of every team below it, so a child team's members receive the parent team's access. Because it is an ordinary group, separation of duties, invariants, access reviews, role mining, relationships, and `principal.groups` all see team members. Only this API writes a backing group's members: the groups API refuses them with `TEAM_MANAGED` (409), and access packages, invitations, onboarding flows, and configuration sync leave them alone. Policies see `principal.teams`: the IDs of the teams a person belongs to directly and of every team above them. ## Team sync [#team-sync] A team with `syncGroupIds` (up to ten ordinary groups, such as SCIM-provisioned directory groups) keeps their live members (active people) as members, marked `source: 'sync'` and ending when their last source membership does. The team follows `groups.addMember`, `groups.updateMember`, `groups.removeMember`, and every SCIM push of a source group (audited with actor `directory-sync`); people added by hand are never touched. Synced members are changed through the source group: `removeMember` and a new end in `updateMember` refuse them with `INVALID_TRANSITION`. Deleting a source group fails with `RESOURCE_IN_USE` while a team syncs from it. `syncGroupIds: null` stops syncing and removes the synced members. ## Maintainers [#maintainers] A maintainer of a team, or of any team above it, may add, update and remove members, list candidates, and decide join requests from their own user session without `iam:teams:update`, unless the team's `memberManagement` is `admins`. Such calls are audited with `via: team-maintainer`; separation-of-duties rules and enforced invariants still apply. Administrators need `iam:teams:update` and, like [`groups.addMember`](/docs/reference/api/groups#addmember), authority over the bindings of the team's backing group and of the teams above it. Members of a team (directly or through a team below it) may read it with `get` and `listMembers`. ## Birthright packages [#birthright-packages] [Access package rules](/docs/guides/privileged-access/automatic-assignment) may test `identity.teams`: the team IDs a person belongs to and those above them (a membership team sync copied from a group counts only through a group membership no package created). The membership calls here re-evaluate the rules for the people they touch once they commit, so someone added to a team gets its birthright packages at once and loses them when they leave. ## Membership reviews [#membership-reviews] A review asks a team's maintainers to confirm who still belongs. An administrator opens it with `startReview`; every live manual member becomes an item (members team sync manages are reviewed through their source groups). Maintainers of the team or a team above, and administrators, record `keep` or `remove` with `decideReview`; nobody decides on their own membership. Nothing changes until the review completes (`completeReview`, or the scheduler job `iam.closeOverdueTeamReviews()` once `dueAt` passes): then people decided `remove` leave the team and people nobody decided on follow `onUndecided`. Removals are audited as `team:member:remove` with `source: review`, and birthright packages follow at once. | Method | What it does | Access | | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`addMember`](#addmember) | Adds a person to a team as a `member` (default) or `maintainer`, optionally until `expiresAt`. | Credential | | [`addMembers`](#addmembers) | Adds up to 100 people with the same role and expiry in one transaction; one failure rejects the batch. | Credential | | [`approveRequest`](#approverequest) | Grants a pending join request: the requester joins as a member (optionally until `expiresAt`) and is emailed (`team-join-decided`). Nobody decides their own request. | Credential | | [`cancelRequest`](#cancelrequest) | Withdraws your own pending join request. | Credential | | [`cancelReview`](#cancelreview) | Cancels an open membership review without changing the team. | Credential | | [`candidates`](#candidates) | People who could be added: active people of the organization who are not direct members, matched on name or email by `query`, at most `limit` (default 50, up to 200). Maintainers use it to pick people without `iam:identities:read`. | Credential | | [`completeReview`](#completereview) | Completes an open review: people decided `remove` leave the team, and people nobody decided on follow the review's `onUndecided`. Returns the review with its `outcome` (`kept`, `removed`, `undecided`, and `gone` for people who had already left or are now managed by team sync). | Credential | | [`create`](#create) | Creates a team with its backing group. `slug` defaults to one derived from the name; `parentId` nests it; `departmentId` files it under a department; `joinPolicy` (`closed` or `request`) and `memberManagement` (`maintainers` or `admins`) set how people join; `maintainerIds` names up to 20 maintainers; `syncGroupIds` turns on [team sync](#team-sync). | Credential | | [`decideReview`](#decidereview) | Records `keep` or `remove` for up to 200 people under an open review, each with an optional `note`. A later decision replaces an earlier one; nothing changes in the team until the review completes. | Credential | | [`delete`](#delete) | Deletes a team, its memberships and join requests, and its backing group with the bindings and relationships on it. | Credential | | [`denyRequest`](#denyrequest) | Refuses a pending join request; the requester is emailed with the `note`. | Credential | | [`get`](#get) | One team with its path (the teams above it), its children, department, maintainers, total member count (with the teams below), and the roles its members hold through it or a team above (`inherited`). | Credential | | [`getReview`](#getreview) | One membership review with every person under it: their role, the decision, who made it and when, and the note. | Credential | | [`leave`](#leave) | Leaves a team you belong to directly. | Credential | | [`list`](#list) | Every team with member, maintainer, and child counts, in name order. Filters: `parentId` (null for top-level teams), `departmentId`, and `query` (name or slug). | Credential | | [`listForIdentity`](#listforidentity) | The teams one person belongs to directly, with their role, expiry, and the teams above each one. | Credential | | [`listMembers`](#listmembers) | The team's live direct members with their role, expiry, and who added them; `includeChildTeams` adds the members of every team below, each with the team they belong to. | Credential | | [`listMine`](#listmine) | Your teams (with role, expiry, and parents), your join requests (newest first), the teams that take join requests that you are not in, and the open membership reviews of teams you maintain ( | | | eviews, soonest due first, with how many people other than you are still undecided). | Credential | | | [`listRequests`](#listrequests) | A team's join requests, pending by default (`status` picks another state; lapsed requests read as `expired`). | Credential | | [`listReviews`](#listreviews) | Membership reviews, newest first (at most 100), without their items: of one team with `teamId`, or of every team. `status` (`open`, `completed`, `cancelled`) filters. | Credential | | [`reconcile`](#reconcile) | Runs [team sync](#team-sync) for every synced team (`synced`: team memberships added, removed, and updated), then recomputes every backing group from team membership, after a restore, an import, or a manual repair: how many group memberships were added, removed, and updated. | Credential | | [`removeMember`](#removemember) | Removes a direct member; the backing groups and the person's activations of their eligible bindings follow. | Credential | | [`requestToJoin`](#requesttojoin) | Asks to join a team whose `joinPolicy` is `request`, with an optional `justification`. The team's maintainers (or, without any, those of the nearest team above) are emailed `team-join-request`; the request lapses after fourteen days. | Credential | | [`startReview`](#startreview) | Opens a membership review of the team, due at `dueAt` (one to 90 days ahead; in 14 days by default), with an optional `note` for the maintainers. `onUndecided` (`keep` by default, or `remove`) settles the people nobody decides on. The team's maintainers (or, without any, those of the nearest team above) are emailed `team-review-requested`. | Credential | | [`suggestBirthright`](#suggestbirthright) | Roles and groups that most of a team's members already hold by hand, proposed as a ready-made automatic access package whose rule names the team. | Credential | | [`update`](#update) | Renames, re-slugs, re-describes, moves (`parentId`, null for top level), or re-files (`departmentId`, null to clear) a team, or changes `joinPolicy`, `memberManagement`, or `syncGroupIds` ([team sync](#team-sync); null stops it). Moving recomputes the backing groups of the old and the new parents. | Credential | | [`updateMember`](#updatemember) | Changes a member's `role` or expiry (`expiresAt: null` makes the membership permanent). | Credential | ## addMember [#addmember] Adds a person to a team as a `member` (default) or `maintainer`, optionally until `expiresAt`. **HTTP:** `POST /api/iam/teams/addMember` (requires a credential) · **Browser client:** `client.teams.addMember()` * **Permission:** `iam:teams:update` on `iam/{teamId}`, or maintaining the team or a team above it. * **Audited as:** `iam:teams:update` and `team:member:add` (`identityId`, `role`, `source`, `expiresAt`, `via`). * **Errors:** `CONFLICT` (409) when the person is already a live member; `INVALID_INPUT` for a service account, agent, or inactive person; `GRANT_AUTHORITY_REQUIRED` / `ACCESS_DENIED` when an administrator lacks authority over what the team holds; `SOD_CONFLICT` (409) when the roles the team brings conflict with the person's; `NOT_FOUND`. A pending join request of the person is marked approved. ```ts await iam.api.teams.addMember(credential, { tenantId, teamId, identityId, role: 'member', expiresAt: Date.now() + 30 * 86_400_000, }); ``` ```ts title="Signature" iam.api.teams.addMember( credential: CredentialInput, input: { tenantId: string; teamId: string; identityId: string; role?: TeamRole; expiresAt?: number; }, ): Promise ``` ## addMembers [#addmembers] Adds up to 100 people with the same role and expiry in one transaction; one failure rejects the batch. **HTTP:** `POST /api/iam/teams/addMembers` (requires a credential) · **Browser client:** `client.teams.addMembers()` * **Permission:** as `addMember`. * **Audited as:** `iam:teams:update` and one `team:member:add` per person. * **Errors:** as `addMember`; `INVALID_INPUT` for an empty or oversized list. ```ts title="Signature" iam.api.teams.addMembers( credential: CredentialInput, input: { tenantId: string; teamId: string; identityIds: string[]; role?: TeamRole; expiresAt?: number; }, ): Promise<{ members: TeamMember[] }> ``` ## approveRequest [#approverequest] Grants a pending join request: the requester joins as a member (optionally until `expiresAt`) and is emailed (`team-join-decided`). Nobody decides their own request. **HTTP:** `POST /api/iam/teams/approveRequest` (requires a credential) · **Browser client:** `client.teams.approveRequest()` * **Permission:** `iam:teams:update` on the team, or maintaining it or a team above it. * **Audited as:** `iam:teams:update`, `team:member:add` (`source: approve`), and `team:join:approve`. * **Errors:** `INVALID_TRANSITION` (409) when the request is no longer pending (decided, withdrawn, or lapsed); `ACCESS_DENIED` for your own request; `NOT_FOUND`. ```ts title="Signature" iam.api.teams.approveRequest( credential: CredentialInput, input: { tenantId: string; requestId: string; expiresAt?: number; note?: string }, ): Promise ``` ## cancelRequest [#cancelrequest] Withdraws your own pending join request. **HTTP:** `POST /api/iam/teams/cancelRequest` (requires a credential) · **Browser client:** `client.teams.cancelRequest()` * **Permission:** None beyond an ordinary user session of the organization (not while impersonating). * **Audited as:** `team:join:cancel`. * **Errors:** `INVALID_TRANSITION` when the request is no longer pending; `NOT_FOUND` for someone else's request. ```ts title="Signature" iam.api.teams.cancelRequest( credential: CredentialInput, input: { tenantId: string; requestId: string }, ): Promise ``` ## cancelReview [#cancelreview] Cancels an open membership review without changing the team. **HTTP:** `POST /api/iam/teams/cancelReview` (requires a credential) · **Browser client:** `client.teams.cancelReview()` * **Permission:** `iam:teams:update` on the team (administrators; maintainers cannot cancel). * **Audited as:** `iam:teams:update` and `team:review:cancel`. * **Errors:** `INVALID_TRANSITION` (409) when the review is no longer open; `NOT_FOUND`. ```ts title="Signature" iam.api.teams.cancelReview( credential: CredentialInput, input: { tenantId: string; reviewId: string }, ): Promise ``` ## candidates [#candidates] People who could be added: active people of the organization who are not direct members, matched on name or email by `query`, at most `limit` (default 50, up to 200). Maintainers use it to pick people without `iam:identities:read`. **HTTP:** `POST /api/iam/teams/candidates` (requires a credential) · **Browser client:** `client.teams.candidates()` * **Permission:** `iam:teams:update` on the team, or maintaining it or a team above it. * **Audited as:** `iam:teams:update`. ```ts title="Signature" iam.api.teams.candidates( credential: CredentialInput, input: { tenantId: string; teamId: string; query?: string; limit?: number }, ): Promise ``` ## completeReview [#completereview] Completes an open review: people decided `remove` leave the team, and people nobody decided on follow the review's `onUndecided`. Returns the review with its `outcome` (`kept`, `removed`, `undecided`, and `gone` for people who had already left or are now managed by team sync). **HTTP:** `POST /api/iam/teams/completeReview` (requires a credential) · **Browser client:** `client.teams.completeReview()` * **Permission:** `iam:teams:update` on the team, or maintaining it (or a team above) once every person is decided. * **Audited as:** `iam:teams:update` (maintainers with `via: team-maintainer`), `team:member:remove` (`source: review`) per removal, and `team:review:complete` with the counts. * **Errors:** `INVALID_TRANSITION` (409) when the review is no longer open, or when a maintainer completes it with people still undecided; `NOT_FOUND`. ```ts title="Signature" iam.api.teams.completeReview( credential: CredentialInput, input: { tenantId: string; reviewId: string }, ): Promise ``` ## create [#create] Creates a team with its backing group. `slug` defaults to one derived from the name; `parentId` nests it; `departmentId` files it under a department; `joinPolicy` (`closed` or `request`) and `memberManagement` (`maintainers` or `admins`) set how people join; `maintainerIds` names up to 20 maintainers; `syncGroupIds` turns on [team sync](#team-sync). **HTTP:** `POST /api/iam/teams/create` (requires a credential) · **Browser client:** `client.teams.create()` * **Permission:** `iam:teams:create` on the tenant. With `parentId`, also `iam:teams:update` on the parent and authority over what the parent (and the teams above it) hold. * **Audited as:** `iam:teams:create`, `team:create`, and `team:member:add` per maintainer. * **Errors:** `CONFLICT` (409) when the slug is taken; `INVALID_INPUT` for a bad slug, more than ten levels of nesting, or more than 20 maintainers; `LIMIT_EXCEEDED` past 1000 teams or the tenant's group limit. ```ts const platform = await iam.api.teams.create(credential, { tenantId, name: 'Platform', joinPolicy: 'request', maintainerIds: [leadId], }); await iam.api.bindings.create(credential, { tenantId, roleId, subjectType: 'group', subjectId: platform.groupId, }); ``` ```ts title="Signature" iam.api.teams.create( credential: CredentialInput, input: TeamInput, ): Promise ``` ## decideReview [#decidereview] Records `keep` or `remove` for up to 200 people under an open review, each with an optional `note`. A later decision replaces an earlier one; nothing changes in the team until the review completes. **HTTP:** `POST /api/iam/teams/decideReview` (requires a credential) · **Browser client:** `client.teams.decideReview()` * **Permission:** `iam:teams:update` on the team, or maintaining it (or a team above). * **Audited as:** `iam:teams:update` (maintainers with `via: team-maintainer`) and `team:review:decide` with the counts. * **Errors:** `ACCESS_DENIED` for a decision on your own membership; `NOT_FOUND` for a person who is not under review; `INVALID_TRANSITION` (409) when the review is no longer open; `INVALID_INPUT` without 1-200 decisions. ```ts await iam.api.teams.decideReview(maintainerSession, { tenantId, reviewId, decisions: [ { identityId: aliceId, decision: 'keep' }, { identityId: carolId, decision: 'remove', note: 'Moved to Sales' }, ], }); ``` ```ts title="Signature" iam.api.teams.decideReview( credential: CredentialInput, input: { tenantId: string; reviewId: string; decisions: Array<{ identityId: string; decision: TeamReviewDecision; note?: string }>; }, ): Promise ``` ## delete [#delete] Deletes a team, its memberships and join requests, and its backing group with the bindings and relationships on it. **HTTP:** `POST /api/iam/teams/delete` (requires a credential) · **Browser client:** `client.teams.delete()` * **Permission:** `iam:teams:delete` on the team, and authority over the backing group's bindings. * **Audited as:** `iam:teams:delete` and `team:delete`. * **Errors:** `RESOURCE_IN_USE` (409) while teams sit below it, while the backing group approves requests for an eligible binding or a package, or while an access package rule names the team (`identity.teams`) or its backing group (`identity.groups`). ```ts title="Signature" iam.api.teams.delete( credential: CredentialInput, input: { tenantId: string; teamId: string }, ): Promise<{ deleted: true; members: number }> ``` ## denyRequest [#denyrequest] Refuses a pending join request; the requester is emailed with the `note`. **HTTP:** `POST /api/iam/teams/denyRequest` (requires a credential) · **Browser client:** `client.teams.denyRequest()` * **Permission:** as `approveRequest`. * **Audited as:** `iam:teams:update` and `team:join:deny`. * **Errors:** as `approveRequest`. ```ts title="Signature" iam.api.teams.denyRequest( credential: CredentialInput, input: { tenantId: string; requestId: string; note?: string }, ): Promise ``` ## get [#get] One team with its path (the teams above it), its children, department, maintainers, total member count (with the teams below), and the roles its members hold through it or a team above (`inherited`). **HTTP:** `POST /api/iam/teams/get` (requires a credential) · **Browser client:** `client.teams.get()` * **Permission:** `iam:teams:read` on the team, or belonging to it (directly or through a team below it). ```ts title="Signature" iam.api.teams.get( credential: CredentialInput, input: { tenantId: string; teamId: string }, ): Promise ``` ## getReview [#getreview] One membership review with every person under it: their role, the decision, who made it and when, and the note. **HTTP:** `POST /api/iam/teams/getReview` (requires a credential) · **Browser client:** `client.teams.getReview()` * **Permission:** `iam:teams:read` on the team, or maintaining it (or a team above). * **Errors:** `NOT_FOUND` for a review of another team or tenant. ```ts title="Signature" iam.api.teams.getReview( credential: CredentialInput, input: { tenantId: string; reviewId: string }, ): Promise ``` ## leave [#leave] Leaves a team you belong to directly. **HTTP:** `POST /api/iam/teams/leave` (requires a credential) · **Browser client:** `client.teams.leave()` * **Permission:** None beyond an ordinary user session of the organization (not while impersonating). * **Audited as:** `team:leave`. * **Errors:** `NOT_FOUND` when you are not a direct member. ```ts title="Signature" iam.api.teams.leave( credential: CredentialInput, input: { tenantId: string; teamId: string }, ): Promise<{ left: true }> ``` ## list [#list] Every team with member, maintainer, and child counts, in name order. Filters: `parentId` (null for top-level teams), `departmentId`, and `query` (name or slug). **HTTP:** `POST /api/iam/teams/list` (requires a credential) · **Browser client:** `client.teams.list()` * **Permission:** `iam:teams:read` on the tenant. ```ts title="Signature" iam.api.teams.list( credential: CredentialInput, input: { tenantId: string; parentId?: string | null; departmentId?: string; query?: string; }, ): Promise ``` ## listForIdentity [#listforidentity] The teams one person belongs to directly, with their role, expiry, and the teams above each one. **HTTP:** `POST /api/iam/teams/listForIdentity` (requires a credential) · **Browser client:** `client.teams.listForIdentity()` * **Permission:** `iam:teams:read` on `iam/{identityId}`. ```ts title="Signature" iam.api.teams.listForIdentity( credential: CredentialInput, input: { tenantId: string; identityId: string }, ): Promise<(TeamRef & { description?: string; role: TeamRole; expiresAt?: number; parents: TeamRef[]; })[]> ``` ## listMembers [#listmembers] The team's live direct members with their role, expiry, and who added them; `includeChildTeams` adds the members of every team below, each with the team they belong to. **HTTP:** `POST /api/iam/teams/listMembers` (requires a credential) · **Browser client:** `client.teams.listMembers()` * **Permission:** `iam:teams:read` on the team, or belonging to it. ```ts title="Signature" iam.api.teams.listMembers( credential: CredentialInput, input: { tenantId: string; teamId: string; includeChildTeams?: boolean }, ): Promise ``` ## listMine [#listmine] Your teams (with role, expiry, and parents), your join requests (newest first), the teams that take join requests that you are not in, and the open membership reviews of teams you maintain ( eviews, soonest due first, with how many people other than you are still undecided). **HTTP:** `POST /api/iam/teams/listMine` (requires a credential) · **Browser client:** `client.teams.listMine()` * **Permission:** None beyond an ordinary user session of the organization. ```ts title="Signature" iam.api.teams.listMine( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listRequests [#listrequests] A team's join requests, pending by default (`status` picks another state; lapsed requests read as `expired`). **HTTP:** `POST /api/iam/teams/listRequests` (requires a credential) · **Browser client:** `client.teams.listRequests()` * **Permission:** `iam:teams:read` on the team, or maintaining it or a team above it. ```ts title="Signature" iam.api.teams.listRequests( credential: CredentialInput, input: { tenantId: string; teamId: string; status?: TeamJoinRequestStatus }, ): Promise ``` ## listReviews [#listreviews] Membership reviews, newest first (at most 100), without their items: of one team with `teamId`, or of every team. `status` (`open`, `completed`, `cancelled`) filters. **HTTP:** `POST /api/iam/teams/listReviews` (requires a credential) · **Browser client:** `client.teams.listReviews()` * **Permission:** With `teamId`, `iam:teams:read` on the team or maintaining it (or a team above); without it, `iam:teams:read` on the tenant. ```ts title="Signature" iam.api.teams.listReviews( credential: CredentialInput, input: { tenantId: string; teamId?: string; status?: TeamReviewStatus }, ): Promise ``` ## reconcile [#reconcile] Runs [team sync](#team-sync) for every synced team (`synced`: team memberships added, removed, and updated), then recomputes every backing group from team membership, after a restore, an import, or a manual repair: how many group memberships were added, removed, and updated. **HTTP:** `POST /api/iam/teams/reconcile` (requires a credential) · **Browser client:** `client.teams.reconcile()` * **Permission:** `iam:teams:update` on the tenant. ```ts title="Signature" iam.api.teams.reconcile( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## removeMember [#removemember] Removes a direct member; the backing groups and the person's activations of their eligible bindings follow. **HTTP:** `POST /api/iam/teams/removeMember` (requires a credential) · **Browser client:** `client.teams.removeMember()` * **Permission:** as `addMember`. * **Audited as:** `iam:teams:update` and `team:member:remove`. * **Errors:** `NOT_FOUND` when the person is not a live direct member; `INVALID_TRANSITION` (409) for a member [team sync](#team-sync) manages. ```ts title="Signature" iam.api.teams.removeMember( credential: CredentialInput, input: { tenantId: string; teamId: string; identityId: string }, ): Promise<{ deleted: true }> ``` ## requestToJoin [#requesttojoin] Asks to join a team whose `joinPolicy` is `request`, with an optional `justification`. The team's maintainers (or, without any, those of the nearest team above) are emailed `team-join-request`; the request lapses after fourteen days. **HTTP:** `POST /api/iam/teams/requestToJoin` (requires a credential) · **Browser client:** `client.teams.requestToJoin()` * **Permission:** None beyond an ordinary user session of the organization (not while impersonating). * **Audited as:** `team:join:request`. * **Errors:** `INVALID_TRANSITION` (409) when the team does not take requests; `CONFLICT` when you are already a member or already asked. ```ts title="Signature" iam.api.teams.requestToJoin( credential: CredentialInput, input: { tenantId: string; teamId: string; justification?: string }, ): Promise ``` ## startReview [#startreview] Opens a membership review of the team, due at `dueAt` (one to 90 days ahead; in 14 days by default), with an optional `note` for the maintainers. `onUndecided` (`keep` by default, or `remove`) settles the people nobody decides on. The team's maintainers (or, without any, those of the nearest team above) are emailed `team-review-requested`. **HTTP:** `POST /api/iam/teams/startReview` (requires a credential) · **Browser client:** `client.teams.startReview()` * **Permission:** `iam:teams:update` on the team (administrators). * **Audited as:** `iam:teams:update` and `team:review:start`. * **Errors:** `CONFLICT` (409) while another review of the team is open; `INVALID_TRANSITION` (409) when the team has no manual members; `INVALID_INPUT` for a `dueAt` outside one to 90 days. ```ts const review = await iam.api.teams.startReview(credential, { tenantId, teamId, onUndecided: 'remove', note: 'Quarterly access review', }); ``` ```ts title="Signature" iam.api.teams.startReview( credential: CredentialInput, input: { tenantId: string; teamId: string; dueAt?: number; onUndecided?: TeamReviewDecision; note?: string; }, ): Promise ``` ## suggestBirthright [#suggestbirthright] Roles and groups that most of a team's members already hold by hand, proposed as a ready-made automatic access package whose rule names the team. **HTTP:** `POST /api/iam/teams/suggestBirthright` (requires a credential) · **Browser client:** `client.teams.suggestBirthright()` * **Permission:** `iam:analysis:read` on the tenant. * **Audited as:** Not audited; it only reads. * **Errors:** `INVALID_INPUT` for `minShare` outside 0.5-1; `NOT_FOUND` for an unknown `teamId`. A team's people are its members and those of every team below it (who a rule naming it would match). It works like [`departments.suggestBirthright`](/docs/reference/api/departments#suggestbirthright): plain grants held by at least `minShare` (default 0.8) of at least `minPeople` (default 3) people, never repeating what is suggested for a team above or what automatic packages already grant, each with a `package` ready for `packages.create`. A child team whose members all belong to its parent's suggestion gets none of its own. ```ts title="Signature" iam.api.teams.suggestBirthright( credential: CredentialInput, input: { tenantId: string; teamId?: string; minShare?: number; minPeople?: number }, ): Promise ``` ## update [#update] Renames, re-slugs, re-describes, moves (`parentId`, null for top level), or re-files (`departmentId`, null to clear) a team, or changes `joinPolicy`, `memberManagement`, or `syncGroupIds` ([team sync](#team-sync); null stops it). Moving recomputes the backing groups of the old and the new parents. **HTTP:** `POST /api/iam/teams/update` (requires a credential) · **Browser client:** `client.teams.update()` * **Permission:** `iam:teams:update` on the team; moving under a parent also needs `iam:teams:update` on it and authority over what it holds. Maintainers cannot change settings. * **Audited as:** `iam:teams:update` and `team:update` (`fields`, and the parents when moved). * **Errors:** `INVALID_INPUT` when moving under itself or a team below it, or past ten levels; `CONFLICT` for a taken slug. ```ts title="Signature" iam.api.teams.update( credential: CredentialInput, input: TeamUpdate, ): Promise ``` ## updateMember [#updatemember] Changes a member's `role` or expiry (`expiresAt: null` makes the membership permanent). **HTTP:** `POST /api/iam/teams/updateMember` (requires a credential) · **Browser client:** `client.teams.updateMember()` * **Permission:** as `addMember`. * **Audited as:** `iam:teams:update` and `team:member:update`. * **Errors:** `NOT_FOUND` when the person is not a live direct member; `INVALID_TRANSITION` (409) for a new end of a synced membership (it follows the source group). ```ts title="Signature" iam.api.teams.updateMember( credential: CredentialInput, input: { tenantId: string; teamId: string; identityId: string; role?: TeamRole; expiresAt?: number | null; }, ): Promise ``` # tenants (/docs/reference/api/tenants) > Tenants are the isolated organizations, projects, and other units your platform serves, arranged in a tree under one root tenant. Tenants are the isolated organizations, projects, and other units your platform serves, arranged in a tree under one root tenant. Each tenant keeps its own directory of identities and its own roles, policies, and audit trail, like an AWS account. This group creates child tenants and invites their first owners, renames, moves, suspends, and deletes them, and sets the per-tenant controls: the public sign-in alias, the authentication policy, the elevation floors, plan limits, and the permission boundary. See [tenants and identities](/docs/guides/concepts/tenants-and-identities) for the model. ## The tenant tree [#the-tenant-tree] Every installation has one root tenant, created by `bootstrap`, and every other tenant has a parent. `hierarchy.types` in the server options decides which tenant types may be created under which (the default is `root → organization → project`), and `hierarchy.maxDepth` how deep the tree may grow (eight levels, counting the root, by default). A type that is not an allowed child fails with `INVALID_HIERARCHY`, and a tree that would grow too deep with `MAX_DEPTH`. | Status | Meaning | Can become | | ----------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------- | | `pending` | Created by [`create`](#create), waiting for its owner to accept the invitation. | `active` when the owner accepts, or `deleted`. | | `active` | In use. Sign-in and authorization need the tenant and every ancestor to be active. | `suspended` or `deleted`. | | `suspended` | Unusable together with its whole subtree; its sessions have ended. | `active` or `deleted`. | | `deleted` | Tombstoned with `deletedAt`; removed by the retention worker after the retention window. | Nothing. | **Who can act on a tenant.** Authorization always happens inside the tenant a call names. You need a session of that tenant (your own account in it, or a role assumed into it through a [trust](/docs/guides/authorization/temporary-access)), or you must be a root administrator: administering a parent grants nothing in its children. While the tenant or any ancestor is not `active`, every decision inside it is refused except a root administrator's. In practice: * [`create`](#create) and [`listChildren`](#listchildren) are checked in the parent, so an organization's administrators can create and list projects under their own organization. * A pending tenant's owner invitations can be listed, re-sent, or revoked only by a root administrator until the owner accepts. * Only a root administrator can reactivate or delete a suspended tenant. Suspending or deleting your own tenant ends your own session too. * Most changes also need recent authentication, which temporary credentials such as role sessions never have. **Aliases.** A tenant may carry a `slug`, unique across the installation, that sign-in pages resolve with the public [`lookup`](#lookup) so people can type an organization name instead of a tenant ID. Email-domain discovery is the other way to find a tenant; see [`domains.discover`](/docs/reference/api/domains#discover). ## Addresses and regions [#addresses-and-regions] With the `hosts` option, an alias is also an address: `acme` signs in at `acme.signin.example.com` (or whatever pattern you configure), and organizations can verify custom hostnames with the [`hostnames`](/docs/reference/api/hostnames) group. With the `regions` option, every tenant has a home region: its own `region`, or its nearest ancestor's. Sign-in for a tenant homed in another region is answered with `WRONG_REGION` and its sign-in URL there, so people land on the deployment that holds their organization. See [sign-in addresses and regions](/docs/operations/deployment/hosts-and-regions). ## Tenant policies and limits [#tenant-policies-and-limits] A tenant carries its own controls on top of the deployment's configuration: | Field | Set with | Who may set it | What it does | | -------------- | ------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------- | | `authPolicy` | [`setAuthPolicy`](#setauthpolicy) | `iam:tenants:update` | Sign-in rules. It can only tighten the deployment's configuration. | | `accessPolicy` | [`setAccessPolicy`](#setaccesspolicy) | `iam:tenants:update` | Minimum rules for activating [eligible bindings](/docs/guides/privileged-access/elevation). | | `limits` | [`setLimits`](#setlimits) | Root only | The most records of each kind the tenant may hold, for SaaS plans. | | `boundary` | [`setBoundary`](#setboundary) | Root only, or the creator at [`create`](#create) | A ceiling on every permission in the tenant and its descendants. | `tenantDefaults` in the server options stamps `limits` and `authPolicy` on every tenant `create` makes, so a plan applies from the first sign-in. Each setter replaces the whole value, so read the tenant with [`get`](#get) and carry the other fields over; `null` clears a policy or the limits. Nothing is cached: sessions are revalidated against the policy on every use, and limits are checked inside every creating transaction. The authentication policy fields (see [tenant authentication policy](/docs/guides/authentication/tenant-policy) for how each is enforced): | Field | Allowed values and effect | | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `requireMfa`, `requireMfaForOwners` | Everyone, or only owners, must complete MFA. | | `allowedMethods` | One or more of `password`, `passwordless-email`, `passwordless-sms`, `passkey`, `federated`. | | `sessionLifetimeMs`, `sessionIdleTimeoutMs` | One minute to 30 days; the idle timeout may not exceed the lifetime. | | `maxSessions` | 1 to 100 concurrent sessions per person; the oldest ends when another is issued. | | `maxAttempts` | 1 to 100,000 authentication attempts per rate-limit window. | | `minPasswordLength`, `passwordMinClasses`, `passwordHistory`, `passwordMaxAgeDays` | 12 to 128 characters; 2 to 4 character classes; 1 to 24 remembered passwords; 1 to 3,650 days. | | `passwordRejectPersonalInfo` | Refuse passwords that contain the person's name or email address. | | `trustedDeviceDays` | 0 to 365 days that "remember this device" may skip MFA; 0 turns it off. | | `allowedIpRanges`, `bindSessionsToIp` | Addresses or CIDR blocks sessions may be issued and used from; a session usable only from the address it was issued to. | | `allowImpersonation`, `notifyNewSignIn`, `mfaEmailCodes` | Allow "view as" sessions; email people about sign-ins from unfamiliar clients; offer emailed MFA codes to people without an authenticator. | Plan limits take the keys `identities` (people, active and disabled), `serviceAccounts`, `groups`, `roles`, `policies`, `resources` (registered managed resources), and `webhooks`, each a whole number from 0 to 1,000,000,000. A missing key means no limit. Creation past a limit fails with `LIMIT_EXCEEDED` on every path: administration, accepted invitations, self-registration, federation, SCIM, and bulk creation. | Method | What it does | Access | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`acceptInvitation`](#acceptinvitation) | Redeems an owner invitation: creates the first owner of a pending tenant, activates the tenant, and signs the owner in. | Public | | [`create`](#create) | Creates a child tenant in the `pending` state under a parent and emails an invitation to its first owner. | Credential | | [`get`](#get) | Returns a tenant with its status, parent, alias, policies, limits, and boundary. | Credential | | [`listChildren`](#listchildren) | Lists the direct children of a tenant, whatever their status. | Credential | | [`listInvitations`](#listinvitations) | Lists a tenant's owner invitations without their tokens. | Credential | | [`lookup`](#lookup) | Resolves a tenant's public alias, or an address it signs in at, to its ID, name, and type, so a sign-in page can ask for an organization name instead of a tenant ID. | Public | | [`reparent`](#reparent) | Moves a tenant, with its whole subtree, under a different parent. | Credential | | [`resendInvitation`](#resendinvitation) | Sends an owner invitation again with a new token and a fresh lifetime; the earlier link stops working. | Credential | | [`revokeInvitation`](#revokeinvitation) | Cancels an owner invitation so its link can no longer be used. | Credential | | [`revokeSessions`](#revokesessions) | Signs everyone out of a tenant at once, keeping your own session unless you pass `includeSelf`. | Credential | | [`setAccessPolicy`](#setaccesspolicy) | Sets organization-wide floors for just-in-time activation that every eligible binding in the tenant must meet. | Credential | | [`setAuthPolicy`](#setauthpolicy) | Sets the tenant's authentication policy: MFA, allowed sign-in methods, session lifetimes, password rules, network restrictions, and impersonation. | Credential | | [`setBoundary`](#setboundary) | Sets a root-controlled permissions boundary on a tenant, capping what anyone in it or in any tenant below it may do. | Credential | | [`setLimits`](#setlimits) | Sets the tenant's plan limits, root only: how many people, service accounts, groups, roles, policies, registered resources, and webhooks it may hold. | Credential | | [`setRegion`](#setregion) | Moves a tenant's home region in a multi-region deployment, root only, so its sign-in is served by that region from then on. | Credential | | [`setSlug`](#setslug) | Sets, changes, or removes the tenant's public sign-in alias. | Credential | | [`setStatus`](#setstatus) | Suspends, reactivates, or deletes a tenant together with its whole subtree. | Credential | | [`update`](#update) | Renames a tenant. | Credential | | [`usage`](#usage) | Returns the tenant's current record counts next to its plan limits, for plan pages and metering. | Credential | ## acceptInvitation [#acceptinvitation] Redeems an owner invitation: creates the first owner of a pending tenant, activates the tenant, and signs the owner in. **HTTP:** `POST /api/iam/tenants/acceptInvitation` (no credential) · **Browser client:** `client.tenants.acceptInvitation()` * **Permission:** None: public. The token from the `owner-invitation` email is the proof. * **Audited as:** `tenant:activate`, with the new owner as the actor. * **Errors:** `INVITATION_INVALID` when the tenant is not pending, the token is unknown, used, revoked, or expired, or the creator's grant authority has been revoked; `TENANT_INACTIVE` when an ancestor is not active; `INVALID_INPUT` for a missing name; `WEAK_PASSWORD` or `BREACHED_PASSWORD` when the password fails the password rules. With `linkCredential`: `LINKING_DISABLED` unless linked onboarding is on, `RECENT_AUTH_REQUIRED`, `PROTECTED_IDENTITY` for a root administrator, `INVALID_LINK` for anything but an ordinary user session, and `ACCESS_DENIED` unless the credential belongs to the person who created the tenant. The owner's email counts as verified. Acceptance creates the protected Owner policy and role, binds the role to the owner under the grant authority reserved when the tenant was created, marks the invitation consumed, and sets the tenant `active`. The result is the owner's public identity plus either `{ token, session }` or an MFA challenge when the tenant's policy (for example from `tenantDefaults`) requires MFA. Over HTTP, a response that issues a session also sets the session cookie. Pass `linkCredential`, the creator's own recently authenticated session, to [link](/docs/reference/api/links#create) the creator's existing account to the new owner account in the same step so they can switch between them. It requires `onboarding: { mode: 'linked' }` in the server options. ```ts const result = await client.tenants.acceptInvitation({ tenantId: params.tenant, token: params.token, name: form.name, password: form.password, }); ``` ```ts title="Signature" iam.api.tenants.acceptInvitation( input: { tenantId: string; token: string; name: string; password: string; linkCredential?: CredentialInput; }, ): Promise ``` ## create [#create] Creates a child tenant in the `pending` state under a parent and emails an invitation to its first owner. **HTTP:** `POST /api/iam/tenants/create` (requires a credential) · **Browser client:** `client.tenants.create()` * **Permission:** `iam:tenants:create` on the parent tenant, with recent authentication, and an active grant authority in the parent. * **Audited as:** `iam:tenants:create`, in the parent tenant's log. * **Errors:** `INVALID_HIERARCHY` when `type` is not an allowed child of the parent's type; `MAX_DEPTH` when the parent is already at the deepest level; `DELIVERY_REQUIRED` without an email delivery callback; `SLUG_TAKEN` or `INVALID_INPUT` for a slug in use or badly formed; `GRANT_AUTHORITY_REQUIRED` without an active grant authority, or `ACCESS_DENIED` when `authorityId` is not one of yours; `INVALID_POLICY` or `INVALID_ACTION` for an invalid boundary; `INVALID_INPUT` for a `region` the deployment does not know, or one other than its own when regions keep separate databases; `RECENT_AUTH_REQUIRED`. The new tenant receives `tenantDefaults` (limits and authentication policy), the optional `slug`, and an optional `boundary`. In a multi-region deployment, `region` sets its home region; without it the tenant inherits its parent's, and an organization directly under the (region-less) root is homed in the region that creates it. A grant authority is reserved for the future owner as a child of yours (or of `authorityId`, one of your own authorities in the parent). That keeps the owner within your authority chain: if your authority is revoked, the invitation can no longer be accepted and the owner's grants stop applying. The token goes only into the `owner-invitation` email; the result has the tenant, the invitation ID, and the owner's address. Nobody but a root administrator can act inside the tenant until the owner accepts. If they never do, a root administrator can re-send or revoke the invitation, or delete the tenant. ```ts const { tenant, invitationId } = await iam.api.tenants.create(credential, { parentId: rootTenantId, type: 'organization', name: 'Acme', ownerEmail: 'owner@acme.example', slug: 'acme', }); // tenant.status === 'pending' until the owner accepts ``` ```ts title="Signature" iam.api.tenants.create( credential: CredentialInput, input: { parentId: string; type: string; name: string; ownerEmail: string; slug?: string; boundary?: PolicyDocument; authorityId?: string; region?: string; }, ): Promise<{ tenant: Tenant; invitationId: string; ownerEmail: string }> ``` ## get [#get] Returns a tenant with its status, parent, alias, policies, limits, and boundary. **HTTP:** `POST /api/iam/tenants/get` (requires a credential) · **Browser client:** `client.tenants.get()` * **Permission:** `iam:tenants:read` on the tenant. * **Audited as:** `iam:tenants:read`. * **Errors:** `NOT_FOUND` when the tenant does not exist. Read it before calling a setter, because each setter replaces its whole value. ```ts title="Signature" iam.api.tenants.get( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listChildren [#listchildren] Lists the direct children of a tenant, whatever their status. **HTTP:** `POST /api/iam/tenants/listChildren` (requires a credential) · **Browser client:** `client.tenants.listChildren()` * **Permission:** `iam:tenants:read` on the tenant. * **Audited as:** `iam:tenants:read`. Pending, suspended, and deleted children are included until they are purged. Only direct children are returned; walking further down needs access to each child, which a root administrator has everywhere. ```ts title="Signature" iam.api.tenants.listChildren( credential: CredentialInput, input: { tenantId: string }, ): Promise ``` ## listInvitations [#listinvitations] Lists a tenant's owner invitations without their tokens. **HTTP:** `POST /api/iam/tenants/listInvitations` (requires a credential) · **Browser client:** `client.tenants.listInvitations()` * **Permission:** `iam:tenants:read` on the tenant. * **Audited as:** `iam:tenants:read`. Each invitation shows the invited email, the grant authority reserved for the owner, when it was created and expires, and whether it was `consumed` or `revoked`. While the tenant is pending, only a root administrator can read them (see [the tenant tree](#the-tenant-tree)). ```ts title="Signature" iam.api.tenants.listInvitations( credential: CredentialInput, input: { tenantId: string }, ): Promise<{ [key: string]: unknown; email: string; createdAt: number; expiresAt: number; authorityId: string; consumed: boolean; revoked?: boolean; id: string; tenantId: string; }[]> ``` ## lookup [#lookup] Resolves a tenant's public alias, or an address it signs in at, to its ID, name, and type, so a sign-in page can ask for an organization name instead of a tenant ID. **HTTP:** `POST /api/iam/tenants/lookup` (no credential) · **Browser client:** `client.tenants.lookup()` * **Permission:** None: public. * **Errors:** `NOT_FOUND` when no active tenant has that alias or address, including pending, suspended, and deleted tenants and tenants under an inactive ancestor; `WRONG_REGION` (421) when another region serves the organization; `INVALID_INPUT` for a malformed slug. Pass `slug` (matched case-insensitively) or `host`, such as `acme.signin.example.com` or a verified custom hostname. With organization addresses or regions configured, the result also names the organization's home `region` and its canonical `signInUrl`. `WRONG_REGION` carries the region and the sign-in URL there (`location`), so a global sign-in page can redirect. `slug` is empty only for an organization without an alias, found by its custom hostname. It is not audited. Aliases are discovery data by design, so apply ingress rate limits to this route and never put anything secret in an alias. ```ts try { const { tenantId } = await client.tenants.lookup({ slug: 'acme' }); await client.auth.signIn({ tenantId, email, password }); } catch (error) { if (error instanceof IamClientError && error.code === 'WRONG_REGION' && error.location) location.assign(error.location); // the organization signs in in another region else throw error; } ``` ```ts title="Signature" iam.api.tenants.lookup( input: { slug?: string; host?: string }, ): Promise ``` ## reparent [#reparent] Moves a tenant, with its whole subtree, under a different parent. **HTTP:** `POST /api/iam/tenants/reparent` (requires a credential) · **Browser client:** `client.tenants.reparent()` * **Permission:** `iam:tenants:update` on the tenant being moved, with recent authentication, plus a grant authority in the new parent (a root administrator always has one). * **Audited as:** `iam:tenants:update` and `tenant:reparent` (with the old and new parent). * **Errors:** `INVALID_TRANSITION` for the root tenant, a pending tenant, or a deleted one; `INVALID_INPUT` when it is already under that parent; `TENANT_INACTIVE` when the new parent or one of its ancestors is not active; `INVALID_HIERARCHY` when the type is not allowed under the new parent, or the new parent is inside the tenant's own subtree; `MAX_DEPTH` when the moved subtree would end up too deep; `NOT_FOUND` for an unknown parent. Use it when a customer reorganizes, for example to move a project to another organization. Delegation chains are not rewritten: grants inside the moved tenant keep the grant authorities they were issued under. Boundaries of the new ancestors apply from the next request, because boundaries are read from the ancestry at evaluation time. ```ts await iam.api.tenants.reparent(rootCredential, { tenantId: projectId, parentId: otherOrganizationId }); ``` ```ts title="Signature" iam.api.tenants.reparent( credential: CredentialInput, input: { tenantId: string; parentId: string; authorityId?: string }, ): Promise<{ parentId: string; name: string; type: string; status: TenantStatus; slug?: string; region?: string; boundary?: PolicyDocument; authPolicy?: TenantAuthPolicy; accessPolicy?: TenantAccessPolicy; limits?: TenantLimits; createdAt: number; deletedAt?: number; id: string; tenantId: string; uniqueKey?: string; }> ``` ## resendInvitation [#resendinvitation] Sends an owner invitation again with a new token and a fresh lifetime; the earlier link stops working. **HTTP:** `POST /api/iam/tenants/resendInvitation` (requires a credential) · **Browser client:** `client.tenants.resendInvitation()` * **Permission:** `iam:tenants:update` on the invitation, with recent authentication. * **Audited as:** `iam:tenants:update`. * **Errors:** `CONFLICT` when the invitation was already accepted or revoked; `DELIVERY_REQUIRED` without an email delivery callback; `NOT_FOUND` when the invitation is not in this tenant; `RECENT_AUTH_REQUIRED`. Use it when the owner's email expired or got lost: expired invitations can be resent, and the invitation keeps the grant authority reserved for the owner. While the tenant is pending, only a root administrator can call it. ```ts title="Signature" iam.api.tenants.resendInvitation( credential: CredentialInput, input: { tenantId: string; invitationId: string }, ): Promise<{ invitationId: string; email: string; expiresAt: number }> ``` ## revokeInvitation [#revokeinvitation] Cancels an owner invitation so its link can no longer be used. **HTTP:** `POST /api/iam/tenants/revokeInvitation` (requires a credential) · **Browser client:** `client.tenants.revokeInvitation()` * **Permission:** `iam:tenants:update` on the invitation, with recent authentication. * **Audited as:** `iam:tenants:update`. * **Errors:** `CONFLICT` when the invitation was already accepted or revoked; `NOT_FOUND` when it is not in this tenant; `RECENT_AUTH_REQUIRED`. The tenant stays pending. A pending tenant can only be activated through its owner invitation, and a revoked invitation cannot be re-sent, so to invite a different owner, delete the tenant with [`setStatus`](#setstatus) and create it again. ```ts title="Signature" iam.api.tenants.revokeInvitation( credential: CredentialInput, input: { tenantId: string; invitationId: string }, ): Promise<{ revoked: boolean; email: string; createdAt: number; expiresAt: number; authorityId: string; consumed: boolean; id: string; tenantId: string; }> ``` ## revokeSessions [#revokesessions] Signs everyone out of a tenant at once, keeping your own session unless you pass `includeSelf`. **HTTP:** `POST /api/iam/tenants/revokeSessions` (requires a credential) · **Browser client:** `client.tenants.revokeSessions()` * **Permission:** `iam:tenants:update` on the tenant, with recent authentication. * **Audited as:** `iam:tenants:update` and `tenant:revoke-sessions` (with the count and `includeSelf`). * **Errors:** `RECENT_AUTH_REQUIRED`. Use it for incident response. It ends every session of the tenant, role sessions assumed from it into other tenants, and "view as" sessions opened through an ended session. It does not distinguish session kinds, so service-account API keys of the tenant, which are stored as sessions, end too and must be issued again. Remembered devices are forgotten as well, so the next sign-in needs the second factor again; you keep yours unless `includeSelf`. Child tenants are not affected. The result's `revoked` is the number of sessions ended. ```ts title="Signature" iam.api.tenants.revokeSessions( credential: CredentialInput, input: { tenantId: string; includeSelf?: boolean }, ): Promise<{ revoked: number }> ``` ## setAccessPolicy [#setaccesspolicy] Sets organization-wide floors for just-in-time activation that every eligible binding in the tenant must meet. **HTTP:** `POST /api/iam/tenants/setAccessPolicy` (requires a credential) · **Browser client:** `client.tenants.setAccessPolicy()` * **Permission:** `iam:tenants:update` on the tenant, with recent authentication. * **Audited as:** `iam:tenants:update` and `tenant:access-policy` (with the new policy). * **Errors:** `INVALID_INPUT` for an unknown field, a non-boolean flag, or a value out of range; `INVALID_TRANSITION` for a deleted tenant; `RECENT_AUTH_REQUIRED`. The fields are `maxActivationMs` (one minute to seven days), `requireJustification`, `requireMfa`, `requireApproval`, and `approvalLifetimeMs` (how long a request waits for a decision, five minutes to thirty days; one day when unset). A binding's effective rules are its own settings tightened by the policy: a flag applies when either sets it, and the maximum activation is the smaller of the two, so adopting a floor later tightens existing bindings without editing them. The call replaces the whole policy; `null` clears it, and flags set to `false` are dropped. See [tenant access policy](/docs/guides/privileged-access/elevation#tenant-access-policy). ```ts await iam.api.tenants.setAccessPolicy(credential, { tenantId, accessPolicy: { maxActivationMs: 4 * 3_600_000, requireJustification: true, requireMfa: true, }, }); ``` ```ts title="Signature" iam.api.tenants.setAccessPolicy( credential: CredentialInput, input: { tenantId: string; accessPolicy: TenantAccessPolicy | null }, ): Promise ``` ## setAuthPolicy [#setauthpolicy] Sets the tenant's authentication policy: MFA, allowed sign-in methods, session lifetimes, password rules, network restrictions, and impersonation. **HTTP:** `POST /api/iam/tenants/setAuthPolicy` (requires a credential) · **Browser client:** `client.tenants.setAuthPolicy()` * **Permission:** `iam:tenants:update` on the tenant, with recent authentication. * **Audited as:** `iam:tenants:update` and `tenant:auth-policy` (with the new policy). * **Errors:** `INVALID_INPUT` for an unknown field or an out-of-range value, or for an `allowedIpRanges` that leaves out your own address when you set it on your own tenant; `INVALID_TRANSITION` for a deleted tenant; `RECENT_AUTH_REQUIRED`. The policy can only tighten the deployment's configuration; the fields are listed under [Tenant policies and limits](#tenant-policies-and-limits). It replaces the whole policy, so carry the existing fields over, and `null` clears it. It applies to existing sessions on their next use: requiring MFA locks out sessions that did not complete it, and sessions issued from outside a new IP allowlist stop working. The self-lockout guard checks both the address your session was issued from and the address of this request, so you cannot cut yourself off from your own organization. ```ts const tenant = await iam.api.tenants.get(credential, { tenantId }); await iam.api.tenants.setAuthPolicy(credential, { tenantId, authPolicy: { ...tenant.authPolicy, requireMfa: true, sessionIdleTimeoutMs: 30 * 60_000 }, }); ``` ```ts title="Signature" iam.api.tenants.setAuthPolicy( credential: CredentialInput, input: { tenantId: string; authPolicy: TenantAuthPolicy | null }, ): Promise ``` ## setBoundary [#setboundary] Sets a root-controlled permissions boundary on a tenant, capping what anyone in it or in any tenant below it may do. **HTTP:** `POST /api/iam/tenants/setBoundary` (requires a credential) · **Browser client:** `client.tenants.setBoundary()` * **Permission:** `iam:boundaries:update` on the tenant, with recent authentication, and you must be a root administrator. * **Audited as:** `iam:boundaries:update`. * **Errors:** `ACCESS_DENIED` for anyone but root; `INVALID_POLICY`, `INVALID_ACTION`, or `INVALID_RESOURCE_TYPE` for a document the catalog rejects; `INVARIANT_VIOLATION` when the change would break an enforced [access invariant](/docs/guides/governance/change-safety); `RECENT_AUTH_REQUIRED`. A boundary never grants. When a request is evaluated, the boundaries of the tenant and every ancestor apply, so a boundary on an organization caps all of its projects. Use it for plan tiers or regulatory scopes that tenant administrators must not be able to widen. Root administrators are not limited by it. The call replaces the boundary; it cannot remove one. See [boundaries](/docs/guides/authorization/policies#boundaries). ```ts // Tenants on this plan may never use the billing export, whatever their roles say. await iam.api.tenants.setBoundary(rootCredential, { tenantId, boundary: { version: 1, statements: [ { effect: 'allow', actions: ['*'], resources: ['*'] }, { effect: 'deny', actions: ['billing:export'], resources: ['*'] }, ], }, }); ``` ```ts title="Signature" iam.api.tenants.setBoundary( credential: CredentialInput, input: { tenantId: string; boundary: PolicyDocument }, ): Promise<{ boundary: PolicyDocument; name: string; type: string; parentId: string | null; status: TenantStatus; slug?: string; region?: string; authPolicy?: TenantAuthPolicy; accessPolicy?: TenantAccessPolicy; limits?: TenantLimits; createdAt: number; deletedAt?: number; id: string; tenantId: string; uniqueKey?: string; }> ``` ## setLimits [#setlimits] Sets the tenant's plan limits, root only: how many people, service accounts, groups, roles, policies, registered resources, and webhooks it may hold. **HTTP:** `POST /api/iam/tenants/setLimits` (requires a credential) · **Browser client:** `client.tenants.setLimits()` * **Permission:** `iam:tenants:update` on the tenant, and you must be a root administrator. * **Audited as:** `iam:tenants:update` and `tenant:limits` (with the new limits). A caller who is not root is recorded as a denial. * **Errors:** `ACCESS_DENIED` for anyone but root; `INVALID_INPUT` for an unknown key or a value that is not a whole number from 0 to 1,000,000,000; `INVALID_TRANSITION` for a deleted tenant. Omitted keys are unlimited and `null` clears every limit. Lowering a limit below current usage removes nothing; it only blocks further creation. The member limit counts active and disabled people, not deleted tombstones. Show current usage next to the limits with [`usage`](#usage). ```ts await iam.api.tenants.setLimits(rootCredential, { tenantId, limits: { identities: 25, serviceAccounts: 5, webhooks: 3 }, }); ``` ```ts title="Signature" iam.api.tenants.setLimits( credential: CredentialInput, input: { tenantId: string; limits: TenantLimits | null }, ): Promise ``` ## setRegion [#setregion] Moves a tenant's home region in a multi-region deployment, root only, so its sign-in is served by that region from then on. **HTTP:** `POST /api/iam/tenants/setRegion` (requires a credential) · **Browser client:** `client.tenants.setRegion()` * **Permission:** `iam:tenants:update` on the tenant, with recent authentication, and you must be a root administrator. * **Audited as:** `iam:tenants:update` and `tenant:region` (with the effective region before and after). A caller who is not root is recorded as a denial. * **Errors:** `ACCESS_DENIED` for anyone but root; `INVALID_INPUT` for a region the deployment does not know, when regions are not configured, or for `null` on the root tenant; `INVALID_TRANSITION` for a deleted tenant; `RECENT_AUTH_REQUIRED`. Descendants without a region of their own move with it; `null` makes the tenant inherit its parent's region again. The call changes where sign-in is served. When your regions share one database that is the whole move; when each region keeps its own database, copy the organization's data to the new region yourself before switching. ```ts await iam.api.tenants.setRegion(rootCredential, { tenantId, region: 'eu-west-1' }); ``` ```ts title="Signature" iam.api.tenants.setRegion( credential: CredentialInput, input: { tenantId: string; region: string | null }, ): Promise ``` ## setSlug [#setslug] Sets, changes, or removes the tenant's public sign-in alias. **HTTP:** `POST /api/iam/tenants/setSlug` (requires a credential) · **Browser client:** `client.tenants.setSlug()` * **Permission:** `iam:tenants:update` on the tenant, with recent authentication. * **Audited as:** `iam:tenants:update`. * **Errors:** `SLUG_TAKEN` (409) when another tenant holds the alias; `INVALID_INPUT` for a badly formed slug; `INVALID_TRANSITION` for a deleted tenant; `RECENT_AUTH_REQUIRED`. A slug is 1 to 63 lowercase letters, digits, or hyphens, neither starting nor ending with a hyphen, and unique across the whole installation; input is lowercased. The previous alias is released at once, so links and bookmarks that use it stop resolving and another tenant may claim it. `slug: null` removes the alias. ```ts title="Signature" iam.api.tenants.setSlug( credential: CredentialInput, input: { tenantId: string; slug: string | null }, ): Promise ``` ## setStatus [#setstatus] Suspends, reactivates, or deletes a tenant together with its whole subtree. **HTTP:** `POST /api/iam/tenants/setStatus` (requires a credential) · **Browser client:** `client.tenants.setStatus()` * **Permission:** `iam:tenants:update` on the tenant to suspend or reactivate, `iam:tenants:delete` to delete; recent authentication either way. * **Audited as:** the action checked, `iam:tenants:update` or `iam:tenants:delete`. * **Errors:** `INVALID_TRANSITION` for the root tenant, a deleted tenant, a pending tenant (which can only be deleted), or an unknown status; `TENANT_INACTIVE` when reactivating under a parent that is not active; `RECENT_AUTH_REQUIRED`. What each change does: * **Suspend** makes the tenant and every descendant unusable at once: sign-in and authorization are refused, and every session and API key in the subtree is deleted, including role sessions sourced from it. Descendants keep their own status, so they become usable again when the tenant is reactivated. * **Reactivate** lets people sign in again. Deleted sessions and API keys are not restored. * **Delete** marks the tenant and every descendant `deleted`, stamps `deletedAt` to start the retention window, and deletes their sessions. The records stay until the retention worker (`purgeDeleted`, the `purge` CLI command, 30 days by default) removes them; audit records survive the purge. A deleted tenant cannot be changed again. Because authorization inside a suspended tenant is refused, only a root administrator can reactivate or delete it. See [scheduled jobs](/docs/operations/jobs) for running the retention worker. ```ts await iam.api.tenants.setStatus(rootCredential, { tenantId, status: 'suspended' }); ``` ```ts title="Signature" iam.api.tenants.setStatus( credential: CredentialInput, input: { tenantId: string; status: 'active' | 'suspended' | 'deleted' }, ): Promise<{ deletedAt?: number; status: 'active' | 'deleted' | 'suspended'; name: string; type: string; parentId: string | null; slug?: string; region?: string; boundary?: PolicyDocument; authPolicy?: TenantAuthPolicy; accessPolicy?: TenantAccessPolicy; limits?: TenantLimits; createdAt: number; id: string; tenantId: string; uniqueKey?: string; }> ``` ## update [#update] Renames a tenant. **HTTP:** `POST /api/iam/tenants/update` (requires a credential) · **Browser client:** `client.tenants.update()` * **Permission:** `iam:tenants:update` on the tenant, with recent authentication. * **Audited as:** `iam:tenants:update`. * **Errors:** `INVALID_INPUT` for an empty name; `INVALID_TRANSITION` for a deleted tenant; `RECENT_AUTH_REQUIRED`. Other properties have their own calls: [`setSlug`](#setslug), [`setAuthPolicy`](#setauthpolicy), [`setAccessPolicy`](#setaccesspolicy), [`setLimits`](#setlimits), [`setBoundary`](#setboundary), [`reparent`](#reparent), and [`setStatus`](#setstatus). ```ts title="Signature" iam.api.tenants.update( credential: CredentialInput, input: { tenantId: string; name: string }, ): Promise<{ name: string; type: string; parentId: string | null; status: TenantStatus; slug?: string; region?: string; boundary?: PolicyDocument; authPolicy?: TenantAuthPolicy; accessPolicy?: TenantAccessPolicy; limits?: TenantLimits; createdAt: number; deletedAt?: number; id: string; tenantId: string; uniqueKey?: string; }> ``` ## usage [#usage] Returns the tenant's current record counts next to its plan limits, for plan pages and metering. **HTTP:** `POST /api/iam/tenants/usage` (requires a credential) · **Browser client:** `client.tenants.usage()` * **Permission:** `iam:tenants:read` on the tenant. * **Audited as:** `iam:tenants:read`. It counts people (`identities`: active and disabled, not deleted) and how many of them have MFA (`mfaEnrolled`: an enabled authenticator or at least one passkey), service accounts, groups, roles, policies, registered resources, relationships, webhooks, and unexpired user sessions (`activeSessions`). `limits` is `{}` when none are set. The counts are read from storage on every call, so cache them for dashboards rather than calling this on every request. ```ts title="Signature" iam.api.tenants.usage( credential: CredentialInput, input: { tenantId: string }, ): Promise<{ tenantId: string; identities: number; mfaEnrolled: number; serviceAccounts: number; groups: number; roles: number; policies: number; resources: number; relationships: number; webhooks: number; activeSessions: number; limits: TenantLimits; }> ``` # trust (/docs/reference/api/trust) > A trust lets one named identity, usually from another tenant, temporarily assume a role in this tenant. A trust lets one named identity, usually from another tenant, temporarily assume a role in this tenant. Identities belong to one tenant and parent membership grants nothing in child tenants, so a support engineer or an automation that must act inside a customer's tenant needs a controlled way in. A trust gives exactly that person short-lived, audited access to one role, instead of a second account or standing access. It works like a role trust policy in cloud IAM. ## How role assumption works [#how-role-assumption-works] 1. A root administrator creates a trust that names the source tenant and identity, the target role, and optional safeguards. 2. The source identity calls [`roles.assume`](/docs/reference/api/roles#assume) with the trust's ID (and the external ID, when the trust requires one). The source also needs `iam:roles:assume` on the role's ID in its own tenant, so its own administrators decide who may use trusts at all. 3. The call returns a short-lived role session token. Requests made with it act in the target tenant with the role's permissions, never more than the trust's `ceiling`. The safeguards are checked on every assumption: * **`requireMfa`** (default `true`): the source session must be MFA-verified. * **`externalId`**: a shared value the caller must present, which protects against a confused deputy (someone tricking a trusted service into assuming the role on their behalf). Only its SHA-256 hash is stored. * **`ceiling`**: a policy document that caps what role sessions may do, whatever the role grants. Without one, the role's own permissions are the limit. A role session cannot assume another role, and a "view as" (impersonation) session cannot assume roles at all. Role sessions stop working at their next use once the trust is revoked. The protected Owner role can never be the target of a trust. ## Trust options and session attributes [#trust-options-and-session-attributes] Identity trusts also carry typed knobs, set at creation or later with [`update`](#update) (root only): * **`maxSessionSeconds`**: the longest role session the trust issues, from 60 up to the deployment's `sts.maxRoleSessionSeconds`; 3600 when unset. * **`passSourceAttributes`**: whether the source identity's attributes reach policies as `principal.{attribute}` in role sessions. New cross-tenant trusts default to `false`, so another tenant's attribute values cannot satisfy this tenant's conditions; same-tenant trusts default to `true`, and trusts created before the option existed keep passing attributes until you change them. [`analysis.findings`](/docs/reference/api/analysis#findings) reports cross-tenant trusts that still pass them. * **`allowedTagKeys`**: the session tag keys a caller may set on `roles.assume` (at most 50, or `['*']` for any); none by default. Tags reach policies as `principal.sessionTags.{key}`. * **`sourceIdentityMode`**: whether a caller may (`optional`), must (`required`), or may not (`forbidden`, the default) name a source identity, which policies read as `principal.sourceIdentity`. * **`description`**: up to 512 characters for the people reviewing the trust. Tags and a source identity are closed by default because a caller chooses their values, and both can satisfy policy conditions. Revocation is covered by [`revokeSessions`](#revokesessions), which ends sessions issued before a point in time without revoking the trust. ## Web-identity trusts [#web-identity-trusts] A trust of kind `web-identity` admits tokens from an external OpenID Connect provider (see [`oidcProviders`](/docs/reference/api/oidc-providers)) instead of a named identity, for CI jobs and workloads that exchange their platform token through [`sts.assumeRoleWithWebIdentity`](/docs/reference/api/sts#assumerolewithwebidentity). Unlike identity trusts, they are tenant-managed: an administrator with `iam:trust:create` on the role creates one, and only that administrator's grant authority (or root) may change or revoke it. Each one names: * the provider (`providerId`) and an active service account of the tenant (`serviceAccountId`) that sessions act as; * `conditions` on the verified token's claims, written in the policy condition grammar over `token.{claim}` keys (nested claims are joined with dots, such as `token.kubernetes.io.namespace`). They must pin `token.sub` with `StringEquals` or `StringLike` without a leading wildcard, or creation fails with `WEAK_TRUST_CONDITIONS`; * optionally `tagClaims` (session tag key to claim name, at most 10), `sourceIdentityClaim`, `maxSessionSeconds`, `ceiling`, `passSourceAttributes` (default `true`), and `description`. Sessions are bounded by the role, the trust's ceiling, and the grant authorities of the trust's and the provider's creators; revoking either authority, disabling the provider or the service account, or revoking the trust ends them. Web identity must be enabled on the deployment (`sts.webIdentity.enabled`) to create, change, or revoke one. | Method | What it does | Access | | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | [`create`](#create) | Creates a trust that lets one source identity assume one role of this tenant. | Credential | | [`evaluateWebIdentity`](#evaluatewebidentity) | Checks what a web-identity trust would conclude about an external token, without issuing anything. | Credential | | [`list`](#list) | Lists the trusts that target this tenant's roles. | Credential | | [`revoke`](#revoke) | Revokes a trust so its source can no longer assume the role, and ends the role sessions already issued under it. | Credential | | [`revokeSessions`](#revokesessions) | Ends the role sessions issued under one trust before a point in time, without revoking the trust. | Credential | | [`update`](#update) | Changes a trust's safeguards and options, such as its MFA requirement, ceiling, session length, attribute passing, tag keys, source identity mode, or a web-identity trust's conditions and claim mappings. | Credential | ## create [#create] Creates a trust that lets one source identity assume one role of this tenant. **HTTP:** `POST /api/iam/trust/create` (requires a credential) · **Browser client:** `client.trust.create()` * **Permission:** `iam:trust:create` on the role (`iam/{roleId}`); root administrators only, with recent authentication. * **Audited as:** `iam:trust:create`. * **Errors:** `ACCESS_DENIED` for anyone but a root administrator; `RECENT_AUTH_REQUIRED` without recent authentication; `PROTECTED_RESOURCE` when the role is the Owner role; `NOT_FOUND` when the role is not in this tenant or the source identity is not in `sourceTenantId`; `INVALID_POLICY`, `INVALID_ACTION`, or `INVALID_RESOURCE_TYPE` for a `ceiling` storage would reject; `INVARIANT_VIOLATION` when the trust would newly break an enforced access invariant. Trusts are platform-controlled because they cross tenant boundaries: a tenant administrator cannot open their tenant to an outside identity on their own. Keep `requireMfa` on; [`analysis.findings`](/docs/reference/api/analysis#findings) reports trusts without it. Give each trust the narrowest ceiling the task needs. With `kind: 'web-identity'` the call creates a [web-identity trust](#web-identity-trusts) instead: `iam:trust:create` on the role without root, recent authentication, and `sts.webIdentity.enabled` (else `FEATURE_DISABLED`). It fails with `WEAK_TRUST_CONDITIONS` when the conditions do not pin `token.sub`, `INVALID_IDENTITY` when the service account is not an active service account, and `INVALID_INPUT` for identity-trust fields such as `sourceTenantId` or `requireMfa`. Both kinds return the public trust, which reports `requiresExternalId` instead of the stored hash. ```ts const trust = await iam.api.trust.create(rootCredential, { tenantId: customerTenantId, sourceTenantId: rootTenantId, sourceIdentityId: supportEngineerId, roleId: supportRoleId, externalId: 'ticket-routing-7f3a', ceiling: { version: 1, statements: [{ effect: 'allow', actions: ['iam:identities:read', 'iam:audit:read'], resources: ['*'] }], }, }); ``` ```ts title="Signature" iam.api.trust.create( credential: CredentialInput, input: TrustCreateInput, ): Promise ``` ## evaluateWebIdentity [#evaluatewebidentity] Checks what a web-identity trust would conclude about an external token, without issuing anything. **HTTP:** `POST /api/iam/trust/evaluateWebIdentity` (requires a credential) · **Browser client:** `client.trust.evaluateWebIdentity()` * **Permission:** `iam:trust:read` on the trust, and `sts.webIdentity.enabled`. * **Audited as:** `iam:trust:read`. * **Errors:** `FEATURE_DISABLED` when web identity is off; `INVALID_INPUT` for a trust that is not a web-identity trust; `NOT_FOUND`. The public exchange answers every refusal with the same `WEB_IDENTITY_REJECTED`, so callers cannot probe which trusts exist. This dry run is how administrators find out why a token is refused: it returns `verified`, the `reason` the exchange would give (a verification failure such as `audience`, `expired`, or `unknown-key`, or `conditions` or `source-identity`), the verified registered claims, each failing condition as `Operator:key`, and the session tags and source identity the claim mappings would produce. No replay record is written, so the token stays redeemable. ```ts const result = await iam.api.trust.evaluateWebIdentity(credential, { tenantId, trustId, webIdentityToken }); if (result.conditions && !result.conditions.matched) console.log(result.conditions.failed); // ['StringLike:token.sub'] ``` ```ts title="Signature" iam.api.trust.evaluateWebIdentity( credential: CredentialInput, input: { tenantId: string; trustId: string; webIdentityToken: string }, ): Promise ``` ## list [#list] Lists the trusts that target this tenant's roles. **HTTP:** `POST /api/iam/trust/list` (requires a credential) · **Browser client:** `client.trust.list()` * **Permission:** `iam:trust:read` on the tenant. * **Audited as:** `iam:trust:read`. Revoked trusts are left out unless `includeRevoked` is `true`. External ID hashes are never returned; instead, `requiresExternalId` tells you whether callers must present one. Tenant administrators can use this to see who outside the tenant may assume which role. Trusts of kind `web-identity`, which admit tokens from an external OpenID Connect provider, appear here too. ```ts title="Signature" iam.api.trust.list( credential: CredentialInput, input: { tenantId: string; includeRevoked?: boolean }, ): Promise ``` ## revoke [#revoke] Revokes a trust so its source can no longer assume the role, and ends the role sessions already issued under it. **HTTP:** `POST /api/iam/trust/revoke` (requires a credential) · **Browser client:** `client.trust.revoke()` * **Permission:** `iam:trust:revoke` on the trust (`iam/{trustId}`); root administrators only, with recent authentication. * **Audited as:** `iam:trust:revoke`. * **Errors:** `ACCESS_DENIED` for anyone but a root administrator; `RECENT_AUTH_REQUIRED` without recent authentication; `NOT_FOUND` when the trust is not in this tenant. Existing role sessions under the trust are refused at their next use. The record is kept with `revoked: true`, so it still appears with `includeRevoked` and in the audit history. Revoking an already revoked trust succeeds. The trust's live role sessions are also deleted in the same transaction, so they disappear from [`roles.listSessions`](/docs/reference/api/roles#listsessions) at once. A web-identity trust is revoked by the administrator whose authority created it (or root) rather than root only, and needs `sts.webIdentity.enabled` (`FEATURE_DISABLED` otherwise). ```ts title="Signature" iam.api.trust.revoke( credential: CredentialInput, input: { tenantId: string; trustId: string }, ): Promise ``` ## revokeSessions [#revokesessions] Ends the role sessions issued under one trust before a point in time, without revoking the trust. **HTTP:** `POST /api/iam/trust/revokeSessions` (requires a credential) · **Browser client:** `client.trust.revokeSessions()` * **Permission:** `iam:roles:revoke-sessions` on the trust's role (`iam/{roleId}`), with recent authentication. It is not root-only, so the target tenant's administrators can end sessions under a platform-controlled trust. * **Audited as:** `iam:roles:revoke-sessions` and `role:sessions-revoked` (with the watermark and the number of sessions deleted). * **Errors:** `INVALID_INPUT` when `before` is not a whole number of milliseconds, is negative, or lies in the future; `NOT_FOUND` when the trust is not in this tenant; `RECENT_AUTH_REQUIRED`; `ACCESS_DENIED`. `before` defaults to now, which ends every session issued so far. The trust's `sessionsRevokedBefore` watermark only moves forward, so older sessions are refused at their next use and the matching rows are deleted at once, while new assumptions keep working. Session JWTs that other services verify offline stay valid there until they expire; those services can check [`sts.getCallerIdentity`](/docs/reference/api/sts#getcalleridentity) for an online answer. ```ts const { revoked } = await iam.api.trust.revokeSessions(credential, { tenantId, trustId }); ``` ```ts title="Signature" iam.api.trust.revokeSessions( credential: CredentialInput, input: TrustRevokeSessionsInput, ): Promise<{ trustId: string; sessionsRevokedBefore: number; revoked: number }> ``` ## update [#update] Changes a trust's safeguards and options, such as its MFA requirement, ceiling, session length, attribute passing, tag keys, source identity mode, or a web-identity trust's conditions and claim mappings. **HTTP:** `POST /api/iam/trust/update` (requires a credential) · **Browser client:** `client.trust.update()` * **Permission:** `iam:trust:update` on the trust, with recent authentication. Identity trusts need a root administrator; web-identity trusts need the grant authority that created them (or root). * **Audited as:** `iam:trust:update`. * **Errors:** `ACCESS_DENIED` for anyone else; `CONFLICT` (409) for a revoked trust; `INVALID_INPUT` when nothing changes or a field belongs to the other kind of trust (`conditions`, `tagClaims`, or `sourceIdentityClaim` on an identity trust; `requireMfa`, `allowedTagKeys`, or `sourceIdentityMode` on a web-identity trust); `WEAK_TRUST_CONDITIONS` for conditions that do not pin `token.sub`; `FEATURE_DISABLED` for a web-identity trust while web identity is off; `RECENT_AUTH_REQUIRED`; `NOT_FOUND`. Only the fields you pass change, and `null` returns `ceiling`, `maxSessionSeconds`, `allowedTagKeys`, `description`, `tagClaims`, or `sourceIdentityClaim` to its default. Tightening what the trust admits (its tag keys, source identity mode, conditions, claim mappings, or a shorter `maxSessionSeconds`) also moves the trust's `sessionsRevokedBefore` watermark to now, so sessions issued under the looser rules end at their next use. Use it to turn off `passSourceAttributes` on older cross-tenant trusts. ```ts await iam.api.trust.update(rootCredential, { tenantId, trustId, passSourceAttributes: false, maxSessionSeconds: 900 }); ``` ```ts title="Signature" iam.api.trust.update( credential: CredentialInput, input: TrustUpdateInput, ): Promise ``` # webhooks (/docs/reference/api/webhooks) > Webhooks push a tenant's audit events to an HTTPS endpoint you run, signed so the endpoint can tell they came from Better IAM. Webhooks push a tenant's audit events to an HTTPS endpoint you run, signed so the endpoint can tell they came from Better IAM. Every change, sign-in, and denial already produces an audit event; a subscription picks the ones you care about by event name, outcome, and resource, and delivers them through the transactional outbox with retries. Use them to feed a SIEM, alert on privilege elevation (`binding:*`), or keep another system in sync without polling [`audit.list`](/docs/reference/api/audit#list). The [webhooks guide](/docs/guides/events/webhooks) walks through a full setup, and [lifecycle events](/docs/guides/events/lifecycle-events) lists the event names beyond `iam:*`. ## Delivery and signing [#delivery-and-signing] A delivery is queued in the same transaction as the event it carries, so a change that rolls back sends nothing and a committed change is never lost. The outbox worker (`iam.auth.dispatchOutbox()`, see [scheduled jobs](/docs/operations/jobs)) sends it as a `POST` with a JSON body and these headers: `X-Better-IAM-Event` (the event name), `X-Better-IAM-Delivery` (the delivery id), `X-Better-IAM-Webhook` (the subscription id), `X-Better-IAM-Timestamp` (Unix seconds), and `X-Better-IAM-Signature` (`v1=` followed by the hex HMAC-SHA256 of `${timestamp}.${body}` under the subscription's secret). The body carries `id` (the audit event id), `type`, `tenantId`, `actorId`, `resourceId`, `outcome`, and `timestamp`, plus optional fields such as `metadata`, `impersonatorId`, and the event's `sequence` and `hash` in the [audit chain](/docs/guides/events/audit-chain). It never contains tokens, secrets, or passwords. Verify every request before trusting it: ```ts import { verifyWebhookSignature } from 'better-iam'; const body = await request.text(); // the raw body, before JSON parsing const valid = verifyWebhookSignature({ secret: process.env.IAM_WEBHOOK_SECRET!, timestamp: request.headers.get('x-better-iam-timestamp')!, body, signature: request.headers.get('x-better-iam-signature')!, }); // constant-time; rejects timestamps more than 300 seconds off (toleranceSeconds) ``` Any non-2xx response, a timeout (`events.webhookTimeoutMs`, ten seconds by default), or a redirect counts as a failed attempt; redirects are never followed. Failed deliveries retry with exponential backoff from 30 seconds up to one hour, and after `authentication.maxDeliveryAttempts` (25 by default) they are abandoned with `failedAt` and `lastError`. Delivery is at least once, so deduplicate on the body's `id`, which stays the same across retries and redeliveries. To hand deliveries to your own queue instead of HTTP, set `events.deliverWebhook`; it receives the already signed request. ## Filters and scope [#filters-and-scope] A subscription receives an event only when all of its filters match: * `events`: 1 to 50 name patterns with `*` and `?` wildcards, such as `iam:identities:*`, `binding:*`, or `auth:signin:fail`. `['*']` means every event. * `outcomes` (optional): `['allow']`, `['deny']`, or both. `['deny']` turns a subscription into a feed of refused operations. * `resources` (optional): 1 to 20 glob patterns of up to 256 characters, matched against the event's `resourceId`, such as `project/*` for events about registered projects. A subscription belongs to one tenant and receives that tenant's events. With `scope: 'subtree'` it also receives the events of every descendant tenant; only the platform root may create one, because membership in a parent tenant grants nothing in its children. A tenant holds at most 50 subscriptions, and its plan limit for webhooks may be lower. | Method | What it does | Access | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------- | | [`create`](#create) | Subscribes an HTTPS endpoint to the tenant's audit events and returns the signing secret, which is shown only once. | Credential | | [`delete`](#delete) | Deletes a subscription and discards the deliveries still waiting to be sent. | Credential | | [`get`](#get) | Returns one subscription without its secret. | Credential | | [`list`](#list) | Lists every subscription of the tenant, without secrets. | Credential | | [`listDeliveries`](#listdeliveries) | Returns the delivery history of one subscription, newest first, with status, attempt count, and the last error. | Credential | | [`ping`](#ping) | Queues a synthetic `webhook:ping` delivery so you can check an endpoint and its signature verification end to end. | Credential | | [`redeliver`](#redeliver) | Queues the event behind an earlier delivery again, rebuilt from the audit log and signed with the current secret. | Credential | | [`rotateSecret`](#rotatesecret) | Replaces a subscription's signing secret and returns the new one, which is shown only once. | Credential | | [`update`](#update) | Changes a subscription's URL, event patterns, filters, description, or active flag. | Credential | ## create [#create] Subscribes an HTTPS endpoint to the tenant's audit events and returns the signing secret, which is shown only once. **HTTP:** `POST /api/iam/webhooks/create` (requires a credential) · **Browser client:** `client.webhooks.create()` * **Permission:** `iam:webhooks:create` on the tenant, with recent authentication. * **Audited as:** `iam:webhooks:create`. * **Errors:** `INVALID_INPUT` for a URL that is not absolute HTTPS, carries credentials or a fragment, or for invalid `events`, `outcomes`, or `resources`; `LIMIT_EXCEEDED` at 50 subscriptions or the tenant's plan limit; `ACCESS_DENIED` for `scope: 'subtree'` unless the caller is the platform root; `RECENT_AUTH_REQUIRED` when the caller has not authenticated recently or uses a temporary credential; `IMPERSONATION_RESTRICTED` in a "view as" session. Store the returned `secret` (it starts with `whsec_`) in your endpoint's configuration right away: only a sealed copy is kept, and no call returns it again. Plain HTTP is accepted only for `localhost`, `127.0.0.1`, or `[::1]` while the deployment itself does not run on HTTPS, which is enough for local development. The subscription starts active. ```ts const { webhook, secret } = await iam.api.webhooks.create(credential, { tenantId, url: 'https://siem.example.com/hooks/iam', events: ['iam:*', 'binding:*', 'auth:signin:fail'], outcomes: ['deny'], description: 'Security feed', }); ``` ```ts title="Signature" iam.api.webhooks.create( credential: CredentialInput, input: { tenantId: string; url: string; events: string[]; description?: string; scope?: 'tenant' | 'subtree'; outcomes?: ('allow' | 'deny')[]; resources?: string[]; }, ): Promise<{ webhook: { [key: string]: unknown; url: string; events: string[]; description?: string; active: boolean; scope: 'tenant' | 'subtree'; outcomes?: ('allow' | 'deny')[]; resources?: string[]; createdAt: number; updatedAt: number; createdBy: string; id: string; tenantId: string; uniqueKey?: string; }; secret: string; }> ``` ## delete [#delete] Deletes a subscription and discards the deliveries still waiting to be sent. **HTTP:** `POST /api/iam/webhooks/delete` (requires a credential) · **Browser client:** `client.webhooks.delete()` * **Permission:** `iam:webhooks:delete` on the webhook, with recent authentication. * **Audited as:** `iam:webhooks:delete`. * **Errors:** `NOT_FOUND` when the webhook is not in this tenant. Delivered and abandoned deliveries stay in storage until the retention sweep removes them. To stop deliveries temporarily and keep the secret, pause the subscription with [`update`](#update) instead. ```ts title="Signature" iam.api.webhooks.delete( credential: CredentialInput, input: { tenantId: string; webhookId: string }, ): Promise<{ deleted: boolean }> ``` ## get [#get] Returns one subscription without its secret. **HTTP:** `POST /api/iam/webhooks/get` (requires a credential) · **Browser client:** `client.webhooks.get()` * **Permission:** `iam:webhooks:read` on the webhook. * **Audited as:** `iam:webhooks:read`. * **Errors:** `NOT_FOUND` when the webhook is not in this tenant. ```ts title="Signature" iam.api.webhooks.get( credential: CredentialInput, input: { tenantId: string; webhookId: string }, ): Promise<{ [key: string]: unknown; url: string; events: string[]; description?: string; active: boolean; scope: 'tenant' | 'subtree'; outcomes?: ('allow' | 'deny')[]; resources?: string[]; createdAt: number; updatedAt: number; createdBy: string; id: string; tenantId: string; uniqueKey?: string; }> ``` ## list [#list] Lists every subscription of the tenant, without secrets. **HTTP:** `POST /api/iam/webhooks/list` (requires a credential) · **Browser client:** `client.webhooks.list()` * **Permission:** `iam:webhooks:read` on the tenant. * **Audited as:** `iam:webhooks:read`. ```ts title="Signature" iam.api.webhooks.list( credential: CredentialInput, input: { tenantId: string }, ): Promise<{ [key: string]: unknown; url: string; events: string[]; description?: string; active: boolean; scope: 'tenant' | 'subtree'; outcomes?: ('allow' | 'deny')[]; resources?: string[]; createdAt: number; updatedAt: number; createdBy: string; id: string; tenantId: string; uniqueKey?: string; }[]> ``` ## listDeliveries [#listdeliveries] Returns the delivery history of one subscription, newest first, with status, attempt count, and the last error. **HTTP:** `POST /api/iam/webhooks/listDeliveries` (requires a credential) · **Browser client:** `client.webhooks.listDeliveries()` * **Permission:** `iam:webhooks:read` on the webhook. * **Audited as:** `iam:webhooks:read`. * **Errors:** `NOT_FOUND` when the webhook is not in this tenant; `INVALID_INPUT` when `limit` is outside 1 to 1000. Each entry has the delivery `id`, the `event` name, the audit `eventId` it carried, `createdAt`, `attempts`, `deliveredAt`, `failedAt`, `lastError`, and a `status` of `pending`, `delivered`, or `failed`. `limit` defaults to 100. Payloads are never returned. Use it to diagnose a failing endpoint (`lastError` holds the HTTP status or network error) and to find the deliveries to [`redeliver`](#redeliver) after an outage. Finished deliveries stay listed until `iam.sweepExpired()` removes them after its delivery retention period (30 days by default). ```ts title="Signature" iam.api.webhooks.listDeliveries( credential: CredentialInput, input: { tenantId: string; webhookId: string; limit?: number }, ): Promise<{ id: string; event: string; eventId: string | undefined; createdAt: number; attempts: number; deliveredAt: number | undefined; failedAt: number | undefined; lastError: string | undefined; status: DeliveryStatus; }[]> ``` ## ping [#ping] Queues a synthetic `webhook:ping` delivery so you can check an endpoint and its signature verification end to end. **HTTP:** `POST /api/iam/webhooks/ping` (requires a credential) · **Browser client:** `client.webhooks.ping()` * **Permission:** `iam:webhooks:update` on the webhook. * **Audited as:** `iam:webhooks:update`. * **Errors:** `INVALID_TRANSITION` when the subscription is paused; `NOT_FOUND` when the webhook is not in this tenant. The ping is sent whatever the subscription's event patterns are. Its body has `type: 'webhook:ping'`, the caller as `actorId`, and the webhook id as `resourceId`. It is not an audit event, so it cannot be redelivered. The call returns the `deliveryId`; follow it with [`listDeliveries`](#listdeliveries) once the outbox worker has run. ```ts title="Signature" iam.api.webhooks.ping( credential: CredentialInput, input: { tenantId: string; webhookId: string }, ): Promise<{ deliveryId: string }> ``` ## redeliver [#redeliver] Queues the event behind an earlier delivery again, rebuilt from the audit log and signed with the current secret. **HTTP:** `POST /api/iam/webhooks/redeliver` (requires a credential) · **Browser client:** `client.webhooks.redeliver()` * **Permission:** `iam:webhooks:update` on the webhook. * **Audited as:** `iam:webhooks:update`. * **Errors:** `NOT_FOUND` when the delivery does not belong to this webhook, or when its audit event no longer exists (for example after `pruneAudit`); `INVALID_TRANSITION` when the subscription is paused or the delivery was a ping. Use it after an endpoint outage outlasted the retries, or to replay an event your consumer lost. It works on any delivery, whatever its status, and creates a new delivery id, so your endpoint must deduplicate on the body's `id` (the audit event id, returned here as `eventId`). ```ts const failed = (await iam.api.webhooks.listDeliveries(credential, { tenantId, webhookId })) .filter((delivery) => delivery.status === 'failed'); for (const delivery of failed) await iam.api.webhooks.redeliver(credential, { tenantId, webhookId, deliveryId: delivery.id }); ``` ```ts title="Signature" iam.api.webhooks.redeliver( credential: CredentialInput, input: { tenantId: string; webhookId: string; deliveryId: string }, ): Promise<{ deliveryId: string; eventId: string }> ``` ## rotateSecret [#rotatesecret] Replaces a subscription's signing secret and returns the new one, which is shown only once. **HTTP:** `POST /api/iam/webhooks/rotateSecret` (requires a credential) · **Browser client:** `client.webhooks.rotateSecret()` * **Permission:** `iam:webhooks:update` on the webhook, with recent authentication. * **Audited as:** `iam:webhooks:update`. * **Errors:** `NOT_FOUND` when the webhook is not in this tenant; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`. Deliveries are signed when they are sent, not when they are queued, so every delivery sent after this call, including retries of older events, uses the new secret. There is no overlap period: update your endpoint right away, or let it accept either secret while you switch. Rotate when a secret may have leaked or when the person who configured the endpoint leaves. ```ts title="Signature" iam.api.webhooks.rotateSecret( credential: CredentialInput, input: { tenantId: string; webhookId: string }, ): Promise<{ webhook: { [key: string]: unknown; url: string; events: string[]; description?: string; active: boolean; scope: 'tenant' | 'subtree'; outcomes?: ('allow' | 'deny')[]; resources?: string[]; createdAt: number; updatedAt: number; createdBy: string; id: string; tenantId: string; uniqueKey?: string; }; secret: string; }> ``` ## update [#update] Changes a subscription's URL, event patterns, filters, description, or active flag. **HTTP:** `POST /api/iam/webhooks/update` (requires a credential) · **Browser client:** `client.webhooks.update()` * **Permission:** `iam:webhooks:update` on the webhook, with recent authentication. * **Audited as:** `iam:webhooks:update`. * **Errors:** `INVALID_INPUT` when nothing is given to change or a value is invalid (the same rules as [`create`](#create)); `NOT_FOUND`; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`. Pass `null` for `description`, `outcomes`, or `resources` to clear it. The `scope` cannot change after creation. `active: false` pauses the subscription: new events are not queued for it, and deliveries already queued are dropped rather than sent. Setting `active: true` resumes it for new events only; use [`redeliver`](#redeliver) for anything you still need from the paused period's queued deliveries. ```ts title="Signature" iam.api.webhooks.update( credential: CredentialInput, input: { tenantId: string; webhookId: string; url?: string; events?: string[]; description?: string | null; active?: boolean; outcomes?: ('allow' | 'deny')[] | null; resources?: string[] | null; }, ): Promise<{ [key: string]: unknown; url: string; events: string[]; description?: string; active: boolean; scope: 'tenant' | 'subtree'; outcomes?: ('allow' | 'deny')[]; resources?: string[]; createdAt: number; updatedAt: number; createdBy: string; id: string; tenantId: string; uniqueKey?: string; }> ``` # Adapters and plugins (/docs/operations/extensions) > The IamStore contract and conformance suite for new storage adapters, and the plugin contract for actions, resource types, endpoints, hooks, and context. Better IAM has two extension points. A storage adapter puts IAM data in a database the reference adapters do not cover. A plugin adds product features that run inside IAM's transactional authorization envelope: new actions and resource types, HTTP endpoints, hooks around every operation, and extra policy context. Both run as trusted server code, so review them like the rest of your server. ## Adapter contract [#adapter-contract] Write an adapter when your data has to live in a database the reference adapters do not support, or behind a storage layer your organization mandates. IAM only ever talks to storage through the `IamStore` interface from `better-iam/core`, so an adapter that honors this contract gets every feature, including the security guarantees that depend on transactions. | Method | What it does | What IAM relies on it for | | ------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `get(collection, id)` | Returns one record, or `undefined`. | Loading a known record, such as a tenant or a session by id. | | `find(collection, filter?, { limit, offset, after }?)` | Returns records matching a strict equality filter, ordered by id. | Every lookup: sessions by token hash, bindings by identity, members by group. | | `insert(collection, record)` | Creates a record; a duplicate id or natural key is a conflict. | Creating anything, with uniqueness (one email per tenant) enforced by the database. | | `put(collection, record)` | Updates an existing record. It is not an upsert. | Changing state without accidentally resurrecting a deleted record. | | `delete(collection, id)` | Removes a record. | Revocations, purges, and the retention sweep. | | `transaction(fn)` | Runs `fn` as one serializable read-modify-write; nested calls join the outer transaction. | Atomic operations: the change and its audit event commit together or not at all. | | `migrate()` | Creates or upgrades the schema. | `iam.initialize()` and `better-iam migrate`. | | `close()` | Releases connections. | Clean shutdown of the application and every CLI command. | | `findOrdered?`, `collections?`, `describe?` | Optional extras; see [below](#optional-methods). | Fast audit paging, snapshots, and `doctor`. | ### Rules every adapter must keep [#rules-every-adapter-must-keep] These rules make the adapters interchangeable, and IAM's security guarantees rest on them. An adapter that bends one can leak access or lose a revocation, so the conformance suite tests each of them. * **Records** have `id`, `tenantId`, optional `uniqueKey`, and JSON-compatible fields. Field values may hold any string. Identifiers (collection, `id`, `tenantId`, `uniqueKey`) containing an unpaired surrogate are refused with `INVALID_RECORD`, because drivers encode text as UTF-8 and would store U+FFFD instead, so two ids would share a row. Reads and deletes treat such identifiers as absent. * **`uniqueKey`** is unique within collection and tenant. Enforce this in the database. * **`put`** updates an existing record; it is not an upsert. Tenant ownership is immutable. * **Filters** use strict, typed equality on top-level fields. The string `"1"` never matches the number `1`, `null` matches only a stored `null`, `undefined` matches only an absent field, and objects compare deeply with key order ignored and array order kept. * **Ordering.** Results are ordered by `id` in code-point order, which is the order of SQLite's `BINARY` and PostgreSQL's `"C"` collation. Pagination uses `limit` and `offset` over that order. `after` is a keyset cursor: only records whose id sorts after it, so each page costs the same however deep it is, and `offset` applies after the cursor. * **Writes require `transaction()`.** Nested transactions join the current transaction and propagate rollback. Do not expose transaction handles after completion. * **Serialization.** Concurrent check-and-write operations must serialize before reading mutable authorization state. Token consumption and last-owner protection depend on this guarantee. * **Errors** map to `IamError` without exposing SQL or serialized credentials. ### Build on `RecordStore` [#build-on-recordstore] The PostgreSQL, SQLite, and libSQL adapters are reference implementations. All three share the `RecordStore` base from `better-iam/core`, so a new adapter only implements a row driver, transaction boundaries, migration, and close. `RecordStore` validates records, applies the filter semantics, and pages results. ```ts import type { RecordDriver, StorageRow } from 'better-iam/core'; // Rows are { collection, id, tenant_id, unique_key, data } with data as a JSON string. const driver: RecordDriver = { async select(collection, id, tenantId): Promise { /* SELECT ... WHERE collection = ? [AND id = ?] [AND tenant_id = ?] */ }, async insert(row) { /* INSERT; map unique violations to a conflict */ }, async update(row) { /* UPDATE ... WHERE collection = ? AND id = ? AND tenant_id = ?; return whether a row changed */ }, async delete(collection, id) { /* DELETE ... WHERE collection = ? AND id = ? */ }, // Optional: evaluate filters in SQL. // query(query) { ... }, // queryCapabilities: { json: true, order: true }, // collections() { ... }, }; ``` A driver that also implements the optional `query(RecordQuery)` method receives each filter as typed field conditions and evaluates it in SQL. `RecordStore` then pages in SQL whenever the whole filter could be expressed there, and filters the rest in memory. * Values a database cannot compare exactly stay in memory: strings with U+0000 or unpaired surrogates, sparse arrays, and filters beyond `MAX_QUERY_CONDITIONS` (24) keys. * If a driver returns a row the filter rejects, `find` repeats the query without paging and filters in memory, so a lenient driver is slow but never wrong. A driver that drops matching rows cannot be corrected this way, and the conformance suite is designed to catch it. * `RecordStore` passes the keyset cursor to drivers as `RecordQuery.after`, which SQL drivers evaluate as `id > ?`. When a driver ignores it, the extra rows are caught and the query is repeated without paging, so the result stays correct. * Drivers without `query` stay correct and read by collection, id, and tenant only. `planQuery`, `sqliteSelect`, `postgresSelect`, and `applyMigrations` are exported for SQL adapters. `applyMigrations` records named schema steps in an `iam_migrations` table (see [Database operations](/docs/operations/deployment/database#migrations)). ### Optional methods [#optional-methods] Three store methods are optional. Callers fall back when a store lacks them, so implement them when your database can do the work faster than the fallback, or when you need the feature that depends on them: * **`findOrdered(collection, filter, { field, direction, from, to, offset, limit })`** returns records whose field holds a number, ordered by that field and then by id, within the inclusive `from` and `to` bounds. Records without a numeric value are left out. The audit log reads through it. A driver opts in with `queryCapabilities: { order: true }`. Callers use the exported `findOrdered(store, ...)` helper, which sorts in memory for stores without the method and accepts an extra `where` predicate that it applies page by page. * **`collections()`** lists the collections that hold records. Snapshots (`exportStore`, `importStore`, `copyStore`) rely on it; a driver provides it with `collections()` returning the distinct collection names. * **`describe()`** returns a `StoreDescription` for `doctor`: adapter name, schema version, applied migrations, record counts per collection, and adapter settings. `describeRecords` builds one from any SQL executor. `instrumentStore(store, onCall)` wraps any store and reports each call, which helps verify that a new adapter's lookups stay selective. It forwards the optional methods only when the wrapped store has them. ### Conformance suite [#conformance-suite] The conformance suite is how you prove a new adapter behaves exactly like the reference ones before trusting it with production data. `@better-iam/core/conformance` exports the behavioral contract as framework-agnostic cases, and every reference adapter runs it in `tests/adapter-conformance.test.ts`. Give each case a fresh, migrated store: ```ts title="my-adapter.test.ts" import { describe, it } from 'vitest'; import { adapterConformanceCases } from '@better-iam/core/conformance'; import { myAdapter } from './my-adapter'; describe('my adapter', () => { for (const test of adapterConformanceCases()) it(test.name, async () => { const store = myAdapter(options); await store.migrate(); try { await test.run(store); } finally { await store.close(); } }); }); ``` `runAdapterConformance(createStore)` runs every case and returns `{ passed, failed }` for runners without per-case reporting. The cases cover: * typed filters, including integers above 2^53 and extreme exponents; * strings with U+0000 and unpaired surrogates; * code-point ordering and selective pagination over hundreds of rows; * ordered and bounded reads, and reads of uncommitted writes; * natural-key uniqueness, transaction rollback and nesting, and serialization of concurrent read-modify-write; * record validation and error hygiene. Run the service security tests against a new adapter as well. ## Plugin contract [#plugin-contract] Write a plugin when a product feature should behave like part of IAM. Typical reasons are permissions that tenants can grant in roles and policies, endpoints that are authorized and audited like the built-in API, and rules that must run inside every IAM change. A plugin is a plain object with a unique `id`; everything else is optional. ### Endpoints [#endpoints] Endpoints let a plugin expose its feature over the same HTTP handler and client SDK as the built-in API, with the same authorization, transactions, and audit trail. Each endpoint declares a `path`, the registered `action` it requires, a `validate` function, and a `handler`. ```ts title="plugins/labels.ts" import { IamError, type IamPlugin } from 'better-iam/core'; export const labels: IamPlugin = { id: 'labels', actions: ['labels:create'], endpoints: [ { method: 'POST', path: 'create', action: 'labels:create', validate(value) { const input = value as { tenantId?: unknown; name?: unknown } | null; if (!input || typeof input.tenantId !== 'string' || typeof input.name !== 'string') { throw new IamError('INVALID_INPUT', 'tenantId and name required'); } return { tenantId: input.tenantId, name: input.name }; }, async handler({ store, tenantId }, input) { return store.insert('labels', { id: crypto.randomUUID(), tenantId, name: String(input.name) }); }, }, ], }; ``` The endpoint mounts at `POST /api/iam/plugins/labels/create`. Before the handler runs: 1. the plugin's `validate` runs on the body (it must reject unknown or invalid fields, and may not change the request's `tenantId`); 2. the registered action is authorized for the caller on the tenant, with root override, tenant scope, ancestry boundaries, and policies applied unchanged. The handler then receives `{ store, principal, tenantId, deliver }`: the transaction, the verified principal, the tenant, and `deliver`, which queues an email or SMS through the host's outbox in the same transaction. The operation is audited like any other. Call plugin routes from the browser with the client SDK's `$request`, or from the server with `iam.callPlugin(credential, { pluginId, path, tenantId, input })`. Trusted plugins must not bypass scope checks by reading or writing another collection or tenant arbitrarily. ### Hooks and context [#hooks-and-context] Hooks let a plugin take part in every IAM change, not only its own endpoints: refuse a change that breaks a rule of yours, or update plugin records in the same transaction. `resolveContext` feeds plugin data into policy conditions. This example blocks role changes during a change freeze, counts every operation, and exposes the tenant's plan to policies: ```ts title="plugins/change-freeze.ts" import { IamError, type IamPlugin } from 'better-iam/core'; export const changeFreeze: IamPlugin = { id: 'change-freeze', hooks: { // Runs after authorization, inside the transaction; throwing rolls the operation back. async beforeOperation({ tenantId, action }) { if (action.startsWith('iam:roles:') && (await freezes.isActive(tenantId))) throw new IamError('CHANGE_FREEZE', 'Role changes are frozen', 409); }, async afterOperation({ action, resourceId, result }) { metrics.count(action, { resourceId }); }, }, // Trusted, server-derived keys for policy conditions. async resolveContext(principal) { return { 'app.plan': await billing.planOf(principal.identity.tenantId) }; }, }; ``` Hook inputs carry `store` (the transaction), `principal`, `tenantId`, `action`, and `resourceId`; `afterOperation` adds `result`. ### Lifecycle rules [#lifecycle-rules] * **Migrations** should be idempotent and use the provided transaction. Version migration records explicitly in the plugin's namespace. * **`afterAudit`** executes from the audit dispatcher after commit and must tolerate at-least-once invocation. It shares the queue that [`iam.events.dispatch()`](/docs/operations/jobs#outbox-and-audit-hooks) drains. * **`purge`** runs inside the tenant purge transaction, before the server deletes the purged tenants' own records. * **Validation at construction.** Plugin ids must be unique, every endpoint action must be registered, and endpoints need unique paths, `POST`, a validator, and a handler. ## Reference plugin [#reference-plugin] Read the reference plugin before writing your own: it shows validation, tenant scoping, and purge handling done the way the contracts expect. The `@better-iam/projects` package is a complete plugin built on these contracts. Registering `createProjectsPlugin()` adds the `projects:read` and `projects:write` actions and mounts `create`, `list`, `get`, `update`, `archive`, and `restore` endpoints for tenant-scoped project records. Its purge callback removes a purged tenant's project records in the same transaction as the tenant purge. ```ts import { betterIam } from 'better-iam'; import { createProjectsPlugin } from 'better-iam/projects'; export const iam = betterIam({ // ... plugins: [createProjectsPlugin()], }); ``` ## Next steps [#next-steps] - [Storage adapters](/docs/operations/storage): The three reference adapters your own adapter should behave like. - [Configuration reference](/docs/operations/deployment/configuration#extensions-and-protocols): The `plugins` option and what is validated at construction. - [Security model](/docs/operations/security#operational-responsibilities): Why plugins and resource loaders count as trusted code. # Operations (/docs/operations) > The production checklist for Better IAM, from runtime and secrets to storage, scheduled jobs, observability, and your security responsibilities. Better IAM runs inside your process and stores everything in your database, so production readiness is yours to arrange: a stable secret, a durable database, a handful of scheduled jobs, and the monitoring that tells you when one of them stops. This section covers each of those. Start with the checklist below, then follow the links for the details. ## Production checklist [#production-checklist] ### Run a supported runtime [#run-a-supported-runtime] Use Node.js 22.12 or a newer supported LTS release. The complete authentication and protocol server does not run on edge runtimes. See [Deployment](/docs/operations/deployment). ### Configure the instance deliberately [#configure-the-instance-deliberately] Set a stable, high-entropy `secret` (at least 32 characters), an HTTPS `baseURL`, the exact `trustedOrigins` your browser clients use, a persistent `database`, and the `sendEmail` / `sendSms` delivery callbacks. OAuth, SAML, and session-JWT keys are separate, explicit inputs. See the [configuration reference](/docs/operations/deployment/configuration). ### Migrate on every deploy, bootstrap once [#migrate-on-every-deploy-bootstrap-once] Run `better-iam migrate` before the new release serves traffic. Bootstrap the platform root exactly once, with `BETTER_IAM_ROOT_EMAIL`, `BETTER_IAM_ROOT_NAME`, and `BETTER_IAM_ROOT_PASSWORD` in the environment, and enroll MFA for that account before using it. See [Database operations](/docs/operations/deployment/database). ### Make storage durable and back it up [#make-storage-durable-and-back-it-up] Keep SQLite on its defaults (write-ahead log, `synchronous = FULL`) and PostgreSQL on `synchronous_commit = on`. Back up the database and every key you configured. See [Storage adapters](/docs/operations/storage). ### Schedule the jobs [#schedule-the-jobs] Deliver the outbox and dispatch audit hooks every minute or so; run `purge`, `sweep`, `reconcile`, `digest`, `remind`, `close-certifications`, `monitor-invariants`, and `audit-archive` on their cadences. If you use the OAuth provider, Shared Signals, or SCIM outbound, also run their [protocol jobs](/docs/operations/jobs#protocol-jobs). See [Scheduled jobs](/docs/operations/jobs). ### Observe it [#observe-it] Probe `GET /api/iam/health` from your load balancer, scrape `/api/iam/metrics` with a bearer token of at least 24 characters, feed `observability.onSpan` to your tracer, and pass `X-Request-Id` from your gateway so IAM's spans join your request logs. See [Observability](/docs/operations/observability). ### Harden the edge [#harden-the-edge] Record real client IPs with `http.clientInfo` behind a proxy you control, and add request body limits and network rate limits at the ingress on top of the account-level limits. See [Security model](/docs/operations/security). ### Gate deployments on `doctor` [#gate-deployments-on-doctor] `better-iam doctor --strict` exits non-zero on any error or warning: a schema behind the release, no root tenant, risky durability, a placeholder secret, a missing email transport, or a job that is not running. Run it after every deploy so a broken release stops before it serves traffic. ```sh title="A typical deploy" better-iam migrate --config better-iam.config.mjs better-iam doctor --config better-iam.config.mjs --strict --retention-days 30 ``` ## In this section [#in-this-section] - [Deployment](/docs/operations/deployment): Runtime requirements, the options every production instance sets, environment variables, and the CLI. - [Configuration reference](/docs/operations/deployment/configuration): Every `betterIam()` option with its default and validation rule. - [Database operations](/docs/operations/deployment/database): Migrations, durability, indexes, upgrades, backups, and PostgreSQL integration checks. - [Secrets and keys](/docs/operations/deployment/secrets): What the deployment secret protects, `previousSecrets`, and staged rotation with `rotate-secrets`. - [Protocol mounts](/docs/operations/deployment/protocol-mounts): Mounting OAuth, SAML, and SCIM next to the HTTP handler in a host application. - [Build and release](/docs/operations/deployment/releases): Checks, packing, packed smoke tests, synchronized versions, and publication prerequisites. - [Storage adapters](/docs/operations/storage): PostgreSQL, SQLite, and libSQL/Turso, snapshots between databases, and `doctor`. - [Scheduled jobs](/docs/operations/jobs): Every worker job, its instance function, CLI command, and suggested cadence. - [Observability](/docs/operations/observability): Spans, Prometheus metrics, health checks, and request IDs. - [Security model](/docs/operations/security): Trust boundaries, root authority, authentication guarantees, and operator responsibilities. - [Adapters and plugins](/docs/operations/extensions): The storage adapter contract, its conformance suite, and the plugin contract. # Scheduled jobs (/docs/operations/jobs) > The worker jobs a deployment schedules (outbox, purge, sweep, reconcile, digest, remind, certifications, invariants, audit archive) with cadences and results. Better IAM starts no background work of its own, apart from the optional access-usage writer. Work that happens after a request (delivering email from the outbox, dispatching webhooks and subscribers, expiring access, cleaning up) waits in the database until your scheduler calls the matching function or CLI command. This keeps request latency independent of mail providers and lets you choose where the work runs. Each job is safe to run repeatedly, and `doctor` reports the ones that have stopped running. ## At a glance [#at-a-glance] Each job can run in two ways: call the instance function from a worker in your application, or run the CLI command from a system scheduler. Both do the same work. | Job | Instance function | CLI command | Suggested cadence | | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------- | | Deliver email, SMS, and webhooks | [`iam.auth.dispatchOutbox()`](#outbox-and-audit-hooks) | [`outbox`](/docs/reference/cli#outbox) | Every minute | | Dispatch audit hooks and subscribers | [`iam.events.dispatch()`](/docs/reference/api#dispatchaudithooks) (alias `iam.dispatchAuditHooks()`) | [`outbox`](/docs/reference/cli#outbox) (runs both) | Every minute, in the process that registers subscribers | | Retention worker | [`iam.purgeDeleted()`](/docs/reference/api#purgedeleted) | [`purge`](/docs/reference/cli#purge) | Hourly, at least daily | | Retention sweep | [`iam.sweepExpired()`](/docs/reference/api#sweepexpired) | [`sweep`](/docs/reference/cli#sweep) | Hourly or daily, beside `purge` | | Birthright access packages | [`iam.reconcilePackages()`](/docs/reference/api#reconcilepackages) | [`reconcile`](/docs/reference/cli#reconcile) | Every 15 minutes, after `purge` | | Owners' access digest | [`iam.sendAccessDigest()`](/docs/reference/api#sendaccessdigest) | [`digest`](/docs/reference/cli#digest) | Daily, then `outbox` | | Expiry reminders | [`iam.sendExpiryReminders()`](/docs/reference/api#sendexpiryreminders) | [`remind`](/docs/reference/cli#remind) | Daily, beside `digest` | | Auto-closing certifications | [`iam.closeOverdueCertifications()`](/docs/reference/api#closeoverduecertifications) | [`close-certifications`](/docs/reference/cli#close-certifications) | Hourly or daily | | Invariant monitoring | [`iam.checkInvariants()`](/docs/reference/api#checkinvariants) | [`monitor-invariants`](/docs/reference/cli#monitor-invariants) | Hourly, and after configuration changes | | Continuous audit archiving | [`iam.archiveAudit()`](/docs/reference/api#archiveaudit) | [`audit-archive`](/docs/reference/cli#audit-archive) | Every few minutes | | Audit retention | [`iam.pruneAudit()`](/docs/reference/api#pruneaudit) | [`audit-prune`](/docs/reference/cli#audit-prune) | Per your retention policy, after archiving | | Access usage flush | [`iam.flushAccessUsage()`](/docs/reference/api#flushaccessusage) | None | Automatic; call it before shutdown | | Protocol jobs (OAuth logout, Shared Signals, SCIM outbound) | `logoutEndedSessions()`, `dispatch()`, `syncAll()` on the protocol services | None | See [Protocol jobs](#protocol-jobs) | These are deployment operations: they need no credential, run with the deployment's authority, and record their effects in the audit log where it matters (`identity:expire`, `tenants:purge`, `tenant:access-digest`, `identity:expiry-reminder`, `certification:auto-close`, `invariant:broken`). Protect the scheduler's configuration like root. The CLI commands suit a system scheduler such as cron. Chain commands with `&&` where one must run after another, for example `reconcile` after `purge`: ```sh title="crontab" CONFIG=/etc/better-iam/better-iam.config.mjs # Every minute: deliver email, SMS, and webhooks, then dispatch audit hooks. * * * * * better-iam outbox --config $CONFIG # Every 5 minutes: continuous audit archiving. */5 * * * * better-iam audit-archive --config $CONFIG # Hourly: expire access, sweep, then apply package rules; reconcile again every quarter hour. 0 * * * * better-iam purge --config $CONFIG && better-iam sweep --config $CONFIG && better-iam reconcile --config $CONFIG --fail-on-attention 15,30,45 * * * * better-iam reconcile --config $CONFIG --fail-on-attention # Hourly: due certification campaigns and guardrails. 30 * * * * better-iam close-certifications --config $CONFIG && better-iam monitor-invariants --config $CONFIG # Daily: owner digest and personal reminders, then deliver them. 0 7 * * * better-iam digest --config $CONFIG && better-iam remind --config $CONFIG && better-iam outbox --config $CONFIG ``` Run in-process instead when your application already has a worker: ```ts title="worker.ts" import { iam } from './lib/iam'; // Subscribers registered with iam.events.subscribe live in this process, so dispatch here. setInterval(async () => { const { delivered, failed, abandoned } = await iam.auth.dispatchOutbox(); await iam.events.dispatch(); if (abandoned) alerts.warn('IAM deliveries abandoned', { delivered, failed, abandoned }); }, 60_000); setInterval(async () => { await iam.purgeDeleted(); await iam.sweepExpired(); }, 3_600_000); ``` ## Outbox and audit hooks [#outbox-and-audit-hooks] Requests never talk to your mail provider or a webhook endpoint directly. Instead, a message is written to the outbox in the same transaction as the change that caused it, so a rolled-back change sends nothing and a committed one is never lost. The outbox job then delivers what is waiting. The outbox carries email, SMS, and webhook deliveries in creation order, encrypted at rest. `iam.auth.dispatchOutbox(limit?)` sends up to `limit` due messages, oldest first (default 100, at most 1000), and returns `{ delivered, failed, abandoned }`. Each message is claimed in its own short transaction, so two workers running at the same time never pick up the same message. * A failed attempt is retried with exponential backoff from thirty seconds to one hour and abandoned after `authentication.maxDeliveryAttempts` (default 25), with `failedAt` and `lastError` recorded. * Delivery is at least once. Delivery transports must deduplicate by message ID, and must not log tokens or message payloads. Alert on persistent failures. * Delivery callbacks are invoked outside the write transaction. * Audit records omit passwords, keys, and token bodies. Audit hooks work the same way for your own code. `iam.events.dispatch()` (the same function as `iam.dispatchAuditHooks()`) runs `events.onEvent`, in-process subscribers from `iam.events.subscribe`, and plugin `afterAudit` hooks for committed events. Dispatch is at least once: a handler that throws leaves its row queued for the next run, so handlers must be idempotent by `event.id`. In-process subscribers exist only where your application registered them, so dispatch in that process; the CLI's `outbox` command dispatches too, but only reaches hooks defined in the configuration. `doctor` reports `outbox-stalled` when messages have waited more than 15 minutes, `outbox-abandoned` for messages abandoned in the last day, and `audit-hooks-stalled` when audit hooks have waited more than 15 minutes. ## Retention worker [#retention-worker] Access with an end date must actually end: deleted tenants must eventually leave storage, temporary grants must disappear, and contractors must be disabled on their last day. The retention worker does that housekeeping. `iam.purgeDeleted({ retentionMs })` (CLI `purge`, `--retention-days` from 0 to 3650, default 30) is idempotent and preserves audit records. In one run it: * removes tombstoned tenants past their retention window, including plugin-owned records through plugin `purge` callbacks in the same transaction, and records one `tenants:purge` event per purged root; * deletes expired temporary bindings and ended role activations; * marks stale access requests and package requests expired; * disables identities past their scheduled deactivation (`expiresAt`), revoking their sessions and recording `identity:expire`; * removes lapsed temporary group memberships, with the activations they carried, and ended package assignments; * deletes authentication bookkeeping past its end: rate-limit counters, expired challenges, and lapsed network blocks. It returns `purgedTenants`, `deletedRecords`, `expiredBindings`, `expiredRequests`, `expiredIdentities`, `expiredActivations`, `expiredMemberships`, and `expiredAssignments`. Expired identities and activations are refused at their next use even before the worker runs, so the schedule only affects how quickly status and reports catch up. `doctor` reports `purge-not-running` when expired bindings, memberships, or challenges are more than a day old. ## Retention sweep [#retention-sweep] Sign-ins, OAuth and SAML flows, and deliveries leave records behind after they stop mattering. Without a sweep, storage and some scans grow with traffic; `dispatchOutbox`, for example, reads the whole outbox. `iam.sweepExpired()` (CLI `sweep`) walks the expiry indexes oldest first in short batches and deletes: * user and role sessions, trusted devices, and relationship tuples past their expiry; * OAuth artifacts and login states, and SAML request, relay-state, and assertion-replay records past their expiry. OAuth grants stay 31 days past their expiry, so back-channel logout still reaches the client when a bound session ends later; * delivered or abandoned outbox messages, and abandoned Shared Signals deliveries, once they are older than the delivery retention (counted from delivery or abandonment). This also bounds the webhook delivery history and `redeliver`; * audit hook rows already dispatched (the audit log keeps the events). Some records are never deleted by age: pending deliveries; API keys and any session kind other than user and role (API keys are listed as expired and can be renewed); invitations, access requests, usage records, and SCIM connections. Expired challenges, rate-limit windows, blocks, bindings, and memberships are `purge`'s job. The result is `{ deleted, total, truncated }`, with `deleted` counting records per collection. `doctor` reports `sweep-backlog` when records have been due for more than two days, judged with the sweep's own retention. ## Package reconciliation [#package-reconciliation] Rule-based access packages ("everyone in Engineering gets the Reader role") must follow people as their attributes and groups change. `iam.reconcilePackages()` (CLI `reconcile`) applies those rules, the birthright access described in [Access packages](/docs/guides/privileged-access/access-packages). It assigns and removes automatic assignments under each rule owner's authority, at most `--limit` (1000, up to 10000) changes per organization per run, and prints `assigned`, `refreshed`, `restored`, `ending`, `revoked`, `stale`, `failed`, `suspended`, and `braked`. An unusually large change is held back by a brake (`braked`) until someone confirms it. * `truncated: true` means run it again. * `--tenant` and `--package` scope the run, and `--package ID --confirm` (with `--tenant`) releases changes the brake held back. * `--fail-on-attention` exits non-zero when a change failed, was held back, or a rule is suspended, for alerting. Schedule it every 15 minutes after `purge`: SCIM provisioning, invitations, and group changes only take effect in packages through it. It needs no email transport. ## Digest and reminders [#digest-and-reminders] Expiring access is only useful if someone notices it before it ends. These two jobs tell the right people: owners get a summary of their organization, and each person hears about their own access. `iam.sendAccessDigest()` (CLI `digest`) delivers the [access report](/docs/guides/privileged-access/access-report) by email. For every active organization (or one `--tenant`) whose report has findings, the owners receive an `access-digest` message with the counts and the full report as JSON. Each organization gets at most one digest per 20 hours (`minimumIntervalMs`), so a daily schedule that drifts a little still sends one a day. Each run is recorded as `tenant:access-digest`. `--within-days` (30) and `--unused-days` (30) set the report windows. `iam.sendExpiryReminders()` (CLI `remind`) speaks to the people themselves. Everyone whose account, direct role bindings, group memberships, or package assignments end within `--within-days` (7) gets one `expiry-reminder` email listing them (`items` as JSON with kind, name, and end). Each item is reminded once per end date, so extending access brings a fresh reminder when the new end comes into the window. Each reminder is recorded as `identity:expiry-reminder`. Both need the configured `sendEmail` callback (otherwise `DELIVERY_REQUIRED`). Run `outbox` afterwards to deliver the messages. ## Certifications and invariants [#certifications-and-invariants] Governance features have deadlines and standing rules that nobody should have to enforce by hand. These jobs close reviews on time and watch the rules continuously. `iam.closeOverdueCertifications()` (CLI `close-certifications`) applies every certification campaign (see [Certifications](/docs/guides/governance/certifications)) created with `autoClose` whose due date has passed (or one `--tenant`'s), each in its own transaction, under the campaign creator's grant authority. It prints `{ closed, skipped }` and records `certification:auto-close`. `iam.checkInvariants()` (CLI `monitor-invariants`) evaluates every organization's access invariants (see [Change safety](/docs/guides/governance/change-safety)), or one `--tenant`'s, and records `invariant:broken` and `invariant:restored` when a status changes, so webhooks can alert. ## Continuous audit archiving [#continuous-audit-archiving] The audit chain detects tampering, but only against a reference kept somewhere an attacker with database access cannot reach. Continuous archiving copies every tenant's chain, verified, to storage you choose, and lets you prune the database without losing history. Configure `auditArchive` and schedule `iam.archiveAudit()` (CLI `audit-archive`) every few minutes to keep an independent copy of every tenant's [audit chain](/docs/guides/events/audit-chain): ```ts title="lib/iam.ts" import { betterIam, createJsonlAuditArchive } from 'better-iam/server'; export const iam = betterIam({ // ... auditArchive: createJsonlAuditArchive({ directory: '/var/lib/better-iam/audit' }), // or your own sink, write-once per range: // auditArchive: { // write: (batch) => // putObjectIfAbsent(`${batch.tenantId}/${batch.fromSequence}-${batch.toSequence}`, batch), // }, }); ``` Each run reads every tenant's events after its archive cursor (collection `auditArchiveCursors`), in chain order and in batches (`batchSize`, 1000 by default). Each batch is checked with `verifyAuditChain` against the previous batch's `lastHash` before it is handed to `write`. The cursor moves only after `write` resolves, so a batch can be written again after a crash, possibly covering a longer range. A sink must therefore: * key stored batches by `tenantId`, `fromSequence`, and `toSequence`; * never replace a stored batch with different content, and throw instead. One run at a time holds a tenant through a lease on its cursor (`leaseMs`, 10 minutes by default) that the run renews before each batch. A second run skips the tenant and lists it under `busy`, so overlapping schedules or instances never race each other's batches. The result also reports `archived` per tenant, `batches`, `failed`, `gaps`, and `truncated`; `--tenant` and `--limit` scope a run. * A chain that does not verify (for example an edited row) is reported under `failed` with `AUDIT_CHAIN_BROKEN`, and nothing past it is archived. * A failing sink is reported with `ARCHIVE_WRITE_FAILED`, and a batch that conflicts with a stored one with `ARCHIVE_CONFLICT`. The CLI exits non-zero in all three cases. * Sequences deleted before they were archived are listed under `gaps`. `createJsonlAuditArchive` writes one file per batch, `{tenantId}/{fromSequence}-{toSequence}.jsonl` with zero-padded sequences. Files are write-once: each is written under a unique temporary name, flushed, and published with a hard link that never replaces an existing file, and the directory is flushed too (not possible on Windows). Writing the same batch again is accepted; different events under an existing name are refused with `ARCHIVE_CONFLICT`. A restored or tampered database therefore cannot overwrite archived events. After a crash, files can overlap; read them by `sequence`. ```sh better-iam audit-verify-archive --directory /var/lib/better-iam/audit --tenant TENANT_ID ``` `audit-verify-archive` checks one tenant's archive on its own, without the database or a configuration file, so an auditor can run it on a copy of the archive. It verifies that overlapping copies agree, that no sequence is missing, and that every hash and link recomputes, and exits non-zero (`AUDIT_ARCHIVE_INVALID`) otherwise. `doctor` reports `audit-archive-behind` when a tenant has unarchived events older than a day. ### Audit retention [#audit-retention] Audit logs grow forever unless you prune them, and many retention policies require deleting old events. Pruning keeps the remaining chain verifiable. `iam.pruneAudit({ tenantId, retentionMs })` (CLI `audit-prune --tenant ID`, `--retention-days` default 365) deletes events older than the retention and appends an `audit:prune` checkpoint so the remaining chain still verifies. Once a tenant has an archive cursor, or wherever `auditArchive` is set, it deletes only events the archive already holds, in every process, including ones without the option. It reports `heldForArchive: true` when it stopped early, so the database never drops an event the archive lacks. For one-off checks, `audit-verify` recomputes a tenant's chain straight from storage and exits non-zero when it does not verify, and `audit-export` writes the chain as JSON Lines to a new file (it refuses to overwrite). Both need no credential and record no audit event. ## Access usage flush [#access-usage-flush] With `accessUsage` enabled, IAM counts which actions each identity actually uses, so role mining can point out unused grants. Counting happens in memory and is written in batches (every minute by default, or earlier when the buffer fills), so no request waits on storage. The writer runs by itself; the only job for you is to call `iam.flushAccessUsage()` before the process exits, so the last minute of usage is not lost: ```ts process.on('SIGTERM', async () => { await iam.flushAccessUsage(); // { written } process.exit(0); }); ``` ## Protocol jobs [#protocol-jobs] The [federation services](/docs/federation) keep their own queues and have no CLI commands, because the CLI loads only the IAM instance. Run their jobs in the application process or worker that creates the services, next to the event subscriptions that trigger them early: | Service | Call | Suggested cadence | Why it needs a schedule | | -------------------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | [OAuth/OIDC provider](/docs/federation/oauth-provider#back-channel-logout) | `issuer.logoutEndedSessions()` | Every minute, and after `auth:session:*`, `identity:*`, and `tenant:*` events | Session expiry raises no event, so grants bound to expired sessions are revoked, and back-channel logouts sent, only when it runs. | | [Shared Signals](/docs/federation/shared-signals#delivery) | `signals.dispatch()` | Every minute; `signals.subscribe(iam.events)` delivers new events early | Retries failed deliveries with backoff and prunes delivered records older than a week. | | [SCIM outbound](/docs/federation/scim-outbound#keep-targets-current) | `provisioner.syncAll()` | Every 15 minutes; `provisioner.subscribe(iam.events)` syncs soon after changes | Catches changes that raise no event, such as expiries, and retries failed downstream calls. | ```ts title="worker.ts" import { iam } from './lib/iam'; import { issuer } from './oauth'; import { signals } from './signals'; import { provisioner } from './provisioning'; // React to IAM events as they are dispatched... iam.events.subscribe(['auth:session:*', 'identity:*', 'tenant:*'], () => issuer.logoutEndedSessions()); signals.subscribe(iam.events); provisioner.subscribe(iam.events); // ...and catch up on a schedule. setInterval(() => void issuer.logoutEndedSessions(), 60_000).unref(); setInterval(() => void signals.dispatch(), 60_000).unref(); setInterval(() => void provisioner.syncAll(), 15 * 60_000).unref(); ``` Event subscriptions fire only when `iam.events.dispatch()` runs in the same process (see [Outbox and audit hooks](#outbox-and-audit-hooks)), so dispatch there too. How quickly they react is therefore set by that interval. None of these calls takes a credential, so never expose them over HTTP. ## Nightly checks with a credential [#nightly-checks-with-a-credential] Some useful jobs act as a tenant administrator rather than as the deployment. They run as the session or API key in `BETTER_IAM_TOKEN`, so each run is authorized and audited: ```sh # Lifecycle report for a ticket or chat channel (iam:identities:read, plus bindings and credentials read). better-iam report --config better-iam.config.mjs --tenant TENANT_ID --within-days 30 --unused-days 30 # Fail when unsuppressed high-severity access findings exist (iam:analysis:read). better-iam analyze --config better-iam.config.mjs --tenant TENANT_ID --fail-on high # Fail when production drifted from the reviewed configuration file. better-iam config-plan --config better-iam.config.mjs --tenant TENANT_ID --input tenant.json --fail-on-drift # Fail when an invariant is broken or cannot be evaluated (iam:invariants:read). better-iam check-invariants --config better-iam.config.mjs --tenant TENANT_ID --fail-on-broken # Weekly: role-mining suggestions and peer outliers for cleanup (iam:analysis:read). better-iam mine-roles --config better-iam.config.mjs --tenant TENANT_ID --peer-by attribute:department ``` Treat that token as an administrator credential, and scope it with a role that holds only these read permissions. ## Next steps [#next-steps] - [CLI reference](/docs/reference/cli): Every command with its flags, exit codes, and a complete crontab. - [Doctor](/docs/operations/storage#doctor): The findings that tell you a job has stopped running. - [Observability](/docs/operations/observability): Metrics and spans to alert on stuck deliveries and failures. # Observability (/docs/operations/observability) > Timing spans through observability.onSpan, built-in Prometheus metrics behind a bearer token, the health endpoint, request IDs, and what to alert on. An identity system is on the path of every request, so you need to know when it slows down, starts refusing people, or stops delivering mail. Better IAM reports on itself in three ways: a span for every unit of work, which you can send to any metrics library or tracer; an optional built-in Prometheus collector fed by those spans; and an unauthenticated health endpoint for load balancers. None of them needs an extra dependency. ## Spans [#spans] A span is one timed unit of work: a provisioning operation, an authorization check or reverse query, an authentication call, or an HTTP request. Spans let you chart IAM latency and outcomes next to your own service. `observability.onSpan` receives each one after it completes. | Kind | Named by | Covers | | ----------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------- | | `operation` | The action, such as `iam:identities:create` | Every provisioning operation. | | `authorize`, `listAccessible` | The action asked about | Authorization checks and reverse queries (which resources the caller may act on). | | `authorizeMany` | `authorizeMany` | Batch checks. | | `auth` | The authentication method, such as `signIn` | Authentication calls. | | `http` | The route, such as `identities/create` | Requests through the HTTP handler, with `status` and `requestId`. | The handler must be synchronous and cheap. Exceptions it throws are ignored, so observability can never affect a request. Feed spans to a metrics library or tracer: ```ts title="lib/iam.ts" observability: { onSpan(span) { latency.observe({ kind: span.kind, name: span.name, outcome: span.outcome }, span.durationMs); if (span.outcome === 'denied') denials.inc({ code: span.code ?? '' }); }, }, ``` ## Prometheus metrics [#prometheus-metrics] When you already run Prometheus (or anything that scrapes its text format), you can skip wiring `onSpan` yourself. `observability.metrics` keeps Prometheus-style counters and histograms from the spans, in memory, without any extra dependency: | Metric | Type | Labels | | ---------------------------------- | --------------------------- | ----------------------------------------------------- | | `better_iam_spans_total` | counter | `kind`, `name`, `outcome`, `code` | | `better_iam_span_duration_seconds` | histogram | `kind` (configurable `buckets`) | | `better_iam_http_requests_total` | counter | `path`, `status` | | `better_iam_outbox_messages` | gauge (with `gauges: true`) | `state`: `pending` or `failed` | | `better_iam_sessions_live` | gauge (with `gauges: true`) | `kind`: `user`, `api-key`, `role`, or `session-token` | The collector keeps no tenant labels. Names the caller controls (unknown routes, rejected input, made-up actions) collapse into `(invalid)` or `(unknown)`, and series beyond `maxSeries` (2000 per metric) collapse into `(other)`, so a hostile client cannot grow memory. **Scrape endpoint:** Set a `bearerToken` and point a scraper at `GET /api/iam/metrics` (under your `basePath`) with `Authorization: Bearer` followed by the token. Other requests get 401. The token is compared in constant time; `doctor` warns when it is shorter than 24 characters. ```ts title="lib/iam.ts" observability: { metrics: { bearerToken: process.env.METRICS_TOKEN, gauges: true } }, ``` ```yaml title="prometheus.yml" scrape_configs: - job_name: better-iam metrics_path: /api/iam/metrics scheme: https authorization: type: Bearer credentials_file: /etc/prometheus/better-iam-token static_configs: - targets: ['identity.example.com'] ``` With `gauges: true`, each scrape also reads the outbox and session collections to report the gauges at that moment. Leave it off for very large deployments. **In code:** Without a `bearerToken`, metrics are programmatic only: ```ts observability: { metrics: true }, // Anywhere in your server: const text = iam.metrics?.render(); // Prometheus text exposition format const data = iam.metrics?.snapshot(); // { spans, durations, http } ``` `iam.metrics.reset()` clears the counters. `createMetrics(options)` from `better-iam/server` builds a standalone collector you can feed from your own `onSpan`. Default histogram buckets are 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, and 10 seconds. With both `onSpan` and `metrics` set, every span reaches both. ## Health checks [#health-checks] `GET /api/iam/health` (under your `basePath`) is always available and needs no credential. It performs one database read by primary key, whose cost does not grow with the number of tenants, and reveals nothing else about the deployment: ```json { "status": "ok", "database": "ok", "latencyMs": 3, "time": 1790000000000 } ``` When the store fails, it answers 503 with `{ "status": "unavailable", "database": "error", "time": ... }`. Use it for load-balancer and orchestrator probes. ## Request IDs [#request-ids] Request IDs let you follow one request from your gateway's logs into IAM's spans. A plain `X-Request-Id` header (letters, digits, and `._:-`, at most 128 characters) is echoed on the response, success or error, and appears as `requestId` on the request's `http` span, so IAM latency and outcomes join your gateway's request logs. Other values are ignored. Browser clients on trusted origins can read the header through CORS. ## What to watch [#what-to-watch] Observe these signals and alert on the ones that matter to you: * authentication failures and rate-limit responses (`denied` spans with `RATE_LIMITED`, `IP_NOT_ALLOWED`, `IP_BLOCKED`, or `SESSION_NETWORK_MISMATCH`); * denied and root-override audit events; * database busy errors (`STORAGE_BUSY`, 503); * outbox retries and abandoned deliveries (`better_iam_outbox_messages`, `doctor`'s `outbox-*` findings); * token issuance and revocation. Audit events are the other half of monitoring. Subscribe a webhook or an in-process subscriber to `auth:signin:fail` for brute-force alerting, to `binding:*` for elevation, and filter by `outcomes: ['deny']` to feed a security information and event management system (SIEM). See [Webhooks](/docs/guides/events/webhooks) and the [observability recipes](/docs/guides/recipes/operations#observe-latency-and-outcomes). Public errors never expose SQL or raw protocol assertions, so error codes are safe to log and count. For storage latency, wrap the adapter with `instrumentStore` (see [Database operations](/docs/operations/deployment/database#measuring)). ## Next steps [#next-steps] - [Scheduled jobs](/docs/operations/jobs): The workers whose backlog the gauges and doctor findings report. - [Doctor](/docs/operations/storage#doctor): A one-shot health report for deploy gates and cron. - [Security model](/docs/operations/security): What the denial codes mean and which ones to alert on. # Security model (/docs/operations/security) > Better IAM's trust boundaries, root authority and delegation, authentication guarantees, external identity rules, and the responsibilities that stay with operators. This page states what Better IAM guarantees and what it relies on you for. Read it before a security review or a production launch; the guides linked from each section explain how to use the features it mentions. ## Trust boundaries [#trust-boundaries] A trust boundary separates what Better IAM believes without checking from what it must validate first. The trusted side is the application server, the configured SQL adapter, the cryptographic keys, the protocol configuration, and your application callbacks. Browser data, tenant administrators, external assertions, and bearer credentials are untrusted until validated. Your server remains responsible for calling authorization before it accesses product resources. ## Root and delegation [#root-and-delegation] Administration in Better IAM is delegated down a tenant tree, and the most common way such systems fail is by letting someone grant more than they hold. These rules explain where the top of the tree (the root) comes from and why no delegated path can exceed its source. **Root authority** is a protected boolean capability on a root-tenant human identity. It is validated from current storage and requires an MFA user session. A role called `root-admin`, a matching email, a linked account, or a JWT claim cannot confer it. Root overrides policy restrictions across tenants, but not malformed input, expired credentials, cross-site request forgery (CSRF) protection, signature validation, or resource ownership validation. Cross-tenant root actions are recorded. **Bootstrap and recovery.** Bootstrap runs only against an uninitialized installation. Recovery creates a new root identity through a deployment-operator command and writes an audit event. Protect access to the configuration, the database credentials, and recovery execution as root-equivalent capabilities. **Invitations.** Member invitations store only a token hash, carry the inviter's grant authority, and re-validate that authority, the roles, and the groups when redeemed; a revoked authority or invitation makes the token useless. An inviter needs `iam:identities:create` plus `iam:bindings:create` on each granted role and `iam:groups:update` on each group, so invitations cannot grant more than the inviter could bind directly. **Tenant aliases** are public discovery data by design: `tenants.lookup` needs no credential, like an AWS account alias. Apply ingress rate limits to it and do not encode secrets in aliases. **Managed resource registrations** are trusted authorization inputs. `iam:resources:*` permissions decide who may register or edit them, and attribute values are validated against the declared schema. Access requests grant nothing by themselves. Approval creates bindings under the reviewer's grant authority and re-checks `iam:bindings:create` on every role, so the review permission can never widen what the reviewer could bind directly, and a requester cannot review their own request. Temporary bindings stop granting the moment they expire, before the purge worker removes them. Webhook subscriptions are tenant-scoped. Only root may subscribe to a subtree. Secrets are sealed at rest and shown once, and deliveries carry audit metadata only. Endpoints must verify the HMAC signature (a keyed hash made with the webhook secret) and the timestamp before trusting a delivery. **Delegation limits.** Delegated administrators cannot remove their own parent-controlled ceilings, borrow a superior authority, or edit protected owner definitions. Moving a tenant requires recent authentication plus authority in the destination parent; the hierarchy's type, depth, and cycle rules apply, and delegation chains keep their original grant authorities. Tenant-created actions cannot redefine platform namespaces, and account linking supplies no cross-tenant permission. **Retention purges** are deployment operations. They remove tombstoned tenants after an explicit retention window, invoke plugin purge callbacks in the same transaction, and preserve audit records. ## Authentication [#authentication] Authentication is where most attacks start: guessed and reused passwords, phished codes, stolen cookies. This section lists how each credential is stored and checked, and which controls a tenant or operator can add. ### Passwords [#passwords] Passwords use Argon2id and must be at least 12 characters. By default a small built-in screen also refuses well-known passwords, keyboard walks, sequences, and passwords with fewer than five distinct characters (`authentication.passwordPolicy.blockCommonPasswords: false` turns it off). `passwordPolicy.isBreached` adds a breach corpus: `pwnedPasswords()` from `@better-iam/auth` is a Have I Been Pwned client that sends only a five-character SHA-1 prefix (k-anonymity), times out after three seconds, and fails open unless `failClosed`. `passwordPolicy.check` adds a custom rule. Tenants tighten further through their [authentication policy](/docs/guides/authentication/tenant-policy): * `minPasswordLength`; * `passwordMinClasses`: 2 to 4 character classes; * `passwordRejectPersonalInfo`: no email local part or name word of four or more characters; * `passwordHistory`: refuse the last 1 to 24 passwords, compared with Argon2 against the newest 24 retained hashes, which are deleted with the identity; * `passwordMaxAgeDays`. Every rule applies wherever a password is set: creation, invitations, bulk onboarding, reset, and change. An expired password is refused with `PASSWORD_EXPIRED` only after it has been verified, so expiry never reveals whether a guess was right; the person recovers through password reset. ### Secrets, codes, and factors [#secrets-codes-and-factors] * Secrets and challenges are generated with cryptographic randomness. * Sessions and API keys store token hashes; short authentication codes use keyed digests. * MFA seeds and delivery payloads use authenticated encryption. * Recovery codes are hashed and single-use. * Passkeys verify the challenge, origin, relying-party ID (RP ID, the domain the passkey is bound to), user verification, and cryptographic signatures. ### Sessions [#sessions] Sessions have absolute and idle expiry, are revoked on sensitive credential changes, and can be ended individually or all-but-current by their owner. Client details recorded on sessions (user agent, proxy-derived IP, device label) are informational: nothing about the client is trusted for authorization, and an IP is recorded only when `http.clientInfo` derives it from a proxy header you control. Root and configured tenant MFA requirements also apply to federation and recovery; resetting a password does not remove MFA. Sessions issued through the HTTP handler record the client's `User-Agent` as `session.client.userAgent`. Behind a proxy you control, `http.clientInfo(request)` records the real IP, the user agent, and an optional device `label`; values are trimmed and bounded. Sessions created directly through `iam.api.auth.*` record client details only inside `iam.auth.withClient(info, fn)`. `auth.listSessions` returns the details for device lists, and `auth.revokeOtherSessions` ends every other session of the caller (recent authentication required). ### Rate limits [#rate-limits] Rate limits bound how fast anyone can guess a password, a code, or a recovery answer. Login attempts, MFA, and recovery use shared, persisted rate limits per account, configured through `authentication.rateLimits`: `attempts` (default 10) for ordinary flows such as password sign-in, `sensitiveAttempts` (default 5) for MFA, recovery, and delivery requests, `windowMs` (default fifteen minutes), and a pluggable `limiter`. * The default limiter keeps durable counters in the IAM database. Multi-instance deployments that prefer a shared cache can supply one that implements `consume()`; `createMemoryRateLimiter()` serves single-process deployments and tests. * Refusals (`RATE_LIMITED`, 429) carry `retryAfterMs` in the error body and a `Retry-After` header equal to the window, so clients and proxies can back off. * `ipAttempts` (off by default) adds a counter per client IP and tenant that every authentication flow shares, so credential stuffing and password spraying from one address stop after that many attempts per window no matter how many accounts it names. It only works with a recorded IP (`http.clientInfo` behind your proxy). `identities.unlock` clears a person's counters but never the network's, so size it for the largest office behind one NAT. ### Tenant authentication policy [#tenant-authentication-policy] A tenant's authentication policy (`tenants.setAuthPolicy`, under `iam:tenants:update` with recent authentication) can only tighten the deployment's configuration: require MFA for every person in the tenant, restrict the accepted sign-in methods, and shorten session lifetimes. * Method restrictions are checked before any credential is examined, so a rejected method never reveals whether a password was right. * Existing sessions are re-validated against the policy on their next use: requiring MFA locks out non-MFA sessions immediately, and a person cannot disable MFA while the tenant requires it. * `requireMfaForOwners` applies the same requirement to owners only, so the people who can change the policy are protected first. * Each user session records the method that established it (`session.method`), available to policies as `principal.authMethod`. ### Remember this device [#remember-this-device] People who sign in every day from the same browser should not have to type a code every time, but a stolen password alone must still not be enough. "Remember this device" (`verifyMfa` or `confirmMfa` with `rememberDevice`) issues an opaque device token, stored hashed. The same browser can then satisfy the MFA requirement on later password or passwordless sign-ins. The token lasts `authentication.trustedDeviceLifetimeMs` (30 days by default, one year at most; 0 disables) or the tenant's `trustedDeviceDays`, whichever is shorter. * Root administrators are never remembered, and an invalid or expired token simply leads to the normal challenge. * Every remembered device is forgotten when the person changes their password, email, or factors, when an administrator revokes their sessions, and on `revokeTrustedDevices`. * Sessions established this way carry `trustedDeviceId`, and people see and forget their devices (`listTrustedDevices`, `revokeTrustedDevice`). * The HTTP layer keeps the token in its own `better-iam.device` cookie (HttpOnly, `__Host-` over HTTPS) and injects it into `auth/signIn` and `auth/finishPasswordless` bodies, so the client only sends `rememberDevice: true` once. `auth/revokeTrustedDevices` clears the cookie. ### Emailed one-time codes [#emailed-one-time-codes] Emailed codes (`mfaEmailCodes` on the tenant policy, or `authentication.mfaEmailCodes` for the deployment; off by default) let a tenant require MFA without forcing every member to install an authenticator. When nothing is enrolled, the sign-in response carries `emailCodeAvailable`. `auth.requestMfaCode` then emails a six-digit code bound to that login challenge (hashed at rest, single use, ten minutes at most, sensitive rate limit), and `verifyMfa` accepts it. A code is weaker than an authenticator because it rides on the mailbox. People with an authenticator enrolled, and root administrators always, must use the authenticator or a recovery code, and the address must be verified. ### Passkeys as the second factor [#passkeys-as-the-second-factor] A registered passkey can also serve as the second factor (`beginPasskeyMfa` / `finishPasskeyMfa`), so people who already have a passkey need no separate authenticator app. The assertion is verified like a passkey sign-in (challenge, origin, RP ID, user verification, signature, counter) and is bound to the pending login challenge, which must still be open and belong to the same person; both challenges are consumed together. ### Network allowlists [#network-allowlists] A tenant can restrict where its people sign in from. `allowedIpRanges` (IPv4 or IPv6 addresses or CIDR blocks) refuses session issuance, including impersonation, with `IP_NOT_ALLOWED` when the recorded client IP lies outside every range. A session whose recorded IP falls outside the ranges stops working at its next use, so tightening the list cuts off existing sessions. The check needs an IP: configure `http.clientInfo` behind a proxy you control, because a sign-in without a recorded IP (direct API use, or the handler without `clientInfo`) is not judged. Refusals show up as `denied` spans with code `IP_NOT_ALLOWED`. ### The person's own security trail [#the-persons-own-security-trail] People can read their own authentication trail without any administrative permission: `auth.listSecurityEvents` returns the `auth:*` audit events recorded for their identity, naming the administrator when one acted through impersonation. With `authentication.signInNotifications` (or a tenant's `notifyNewSignIn`), a session that starts from an unfamiliar client queues a `new-sign-in` email. A client is unfamiliar when none of the person's live sessions or remembered devices has used it. The email carries the session ID, time, method, user agent, IP, and label, so a stolen password is noticed quickly. Only sessions with client details are judged, so run sign-ins through the HTTP handler or `auth.withClient`. ### Failed sign-in records [#failed-sign-in-records] Failed attempts against a real, active account are recorded: a wrong password, authenticator or emailed code, or recovery code once the first factor passed. Each becomes an `auth:signin:fail` event with the reason and the client's IP and user agent, written in a transaction of its own after the refused flow rolled back, and is counted in the person's sign-in record. * Each new session carries that record as `session.previousSignIn`: the previous sign-in time and client, the failures since, and the latest failed attempt's time and client. People learn about guessing against their account the next time they sign in, even when no notification email reaches them. * Attempts the rate limiter refused, unknown addresses, and disabled accounts produce no record and no event, so the mechanism neither enumerates accounts nor lets an attacker grow the audit log beyond the rate limit. * `authentication.failedSignInAlerts` (off by default; needs `sendEmail`) turns the count into one `sign-in-failures` email per streak (payload `attempts`, `time`, `ip`, `userAgent`). It goes to verified addresses only, exactly when the streak reaches the threshold. The person hears about it without waiting for their next sign-in, and an attacker cannot use repeated attempts to flood their mailbox. ### Network blocks and IP-bound sessions [#network-blocks-and-ip-bound-sessions] When an attack comes from a known address, or a session cookie may have been stolen, you need to cut access by network without touching each account. Network blocks are the incident-response counterpart of the allowlist. `security.blockNetwork({ tenantId, network, reason, durationMs?, platform? })` (`iam:security:manage`, recent authentication, audited as `security:network-block`) blocks an IPv4 or IPv6 address or CIDR block. Every authentication flow and every live session whose recorded client IP falls in that network is refused with `IP_BLOCKED`, before rate limits or credentials are examined, so a blocked address cannot count against anyone. * Root administrators set `platform` blocks on the root tenant that apply to every tenant; an organization's blocks apply to itself. * A block lapses after `durationMs` (one minute to a year) or stays until `security.unblockNetwork` lifts it; `security.listBlocks` (`iam:security:read`) shows them with `active`. * The caller's own recorded address is refused, so nobody locks themselves out. * The authentication service reuses each tenant's list for five seconds, so a change reaches other processes within that time. * Like the allowlist, blocks need `http.clientInfo` to record addresses. A tenant that wants stolen cookies to be useless elsewhere sets `bindSessionsToIp`. A user session is then accepted only from the address it was issued from; a use from another address is refused with `SESSION_NETWORK_MISMATCH` and recorded in the person's trail as `auth:session:mismatch` (both addresses in the metadata). The person simply signs in again from the new network while the old session keeps working from the old one. Sessions and requests without a recorded address are not judged. The admin console's Sign-in failures page blocks a source address platform-wide for a day in one click and lists the platform's blocks; organization owners manage theirs on the settings page. ### Impersonation [#impersonation] Support staff often need to see exactly what a member sees, without asking for the member's password. Impersonation ("view as", `identities.impersonate`) provides that under strict limits. It is off until a tenant's authentication policy sets `allowImpersonation`. It then needs `iam:identities:impersonate` on the member, recent authentication, an ordinary session of the administrator's own, and a recorded reason. * Owners, root administrators, service accounts, and the caller cannot be impersonated. * The member session inherits the administrator's MFA state, so a member who requires MFA cannot be impersonated without it. It lasts at most eight hours and never longer than the administrator's session, ends the moment the administrator signs out or is disabled, and appears in the member's own session list. * It cannot perform anything that requires recent authentication (password, email, factor, session, and ownership changes), and cannot re-authenticate, assume roles, grant OAuth consent, or impersonate further. * Every audit record it produces carries `impersonatorId` beside the member `actorId`, policies see `principal.impersonated` and `principal.impersonatorId`, assertions carry `impersonatorId`, and the token is never issued as a cookie. See the [impersonation recipe](/docs/guides/recipes/support-and-privacy#support-see-the-product-as-a-member-sees-it). ### Stateless assertions [#stateless-assertions] Downstream services often need to know who is calling without querying IAM on every request. Stateless assertions (`assertions.issue`) are short-lived HS256 JSON Web Tokens describing the caller (identity, tenant, session kind, MFA, method, role and group IDs, optional public claims) for a named audience. Issuing one is authorized as `iam:assertions:create` on `iam/{audience}` and audited, so administrators decide which roles may obtain tokens for which services. Assertions are signed with a key derived from the deployment secret (`iam.assertionKey()`). A service holding only that key verifies with `verifyAssertion` and cannot recover the secret. Assertions grant nothing inside IAM, cannot be exchanged for sessions, and are not revocable before they expire (at most one hour, five minutes by default). Keep lifetimes short, and treat the derived key as a shared secret between IAM and its services. ### Audit chain [#audit-chain] Audit records form a per-tenant hash chain, the audit chain (`sequence`, `previousHash`, `hash`), that `audit.verify`, `audit.export`, and the portable `verifyAuditChain` check; see [Audit chain](/docs/guides/events/audit-chain). The chain detects alteration, reordering, or removal by anyone who cannot rewrite both the events and the chain head. Export chains to independent storage and treat the exported heads as the reference; [continuous audit archiving](/docs/operations/jobs#continuous-audit-archiving) does this on a schedule. ### Cookies and CSRF [#cookies-and-csrf] HTTPS cookies use `__Host-better-iam.session`, Secure, HttpOnly, `SameSite=Lax` (or `Strict` with `http.cookieSameSite`), and `Path=/`. They carry a `Max-Age` equal to the session's remaining lifetime unless the request that issued the session asked for a browser-session cookie (`X-Better-IAM-Persistent: 0`, or `http.persistentCookies: false` as the default). Then the browser drops the cookie when it closes, while the server session still expires on its own schedule. Loopback HTTP development uses a separate non-prefixed cookie name. JSON mutations require `X-Better-IAM: 1`, and cookie requests also require an exact trusted Origin, which together stop other sites from forging requests. No parent-domain cookie is configured. ## External identity [#external-identity] Federation accepts assertions from systems you do not control, so every one is validated before it can create or reach an account. * OAuth sign-in uses state, PKCE, browser-bound callbacks, issuer and subject mappings, and OIDC nonce validation ([how each one helps](/docs/federation/oauth-sign-in#how-sign-in-works)). * SAML uses a maintained verifier plus exact destination, recipient, and in-response-to checks and a shared replay cache ([response validation](/docs/federation/saml#response-validation)). * SCIM connection credentials are tenant-scoped, cannot provision root privileges, and protect owner accounts. An email collision is not proof of account ownership. Explicit linking must prove control of both the existing account and the external provider identity; an unlinked collision returns `ACCOUNT_LINK_REQUIRED`. Configure issuers, endpoints, certificates, signing keys, and redirect URLs only through trusted deployment configuration or authorized administration. See [Federation](/docs/federation). ## Operational responsibilities [#operational-responsibilities] > **These stay with you.** Better IAM cannot enforce the following. Treat them as part of your deployment's security baseline. * **Keys.** Keep application and protocol keys stable, secret, backed up, and separate by purpose. Losing encryption keys makes enrolled factors and queued deliveries unreadable. Rotate the deployment `secret` with `previousSecrets` and `rotate-secrets` ([Secrets and keys](/docs/operations/deployment/secrets)); never replace it outright. * **Delivery.** Require delivery transports to deduplicate IDs and avoid logging tokens or message payloads. * **Tenant scoping.** Keep administrative policy explanations and audit records tenant-scoped. * **Transactions.** Preserve the adapter transaction contract. External side effects belong in an outbox or after commit. * **Ingress.** Use request and body limits and network rate limits at the ingress, in addition to account-level controls. * **Trusted code.** Audit application resource loaders and custom plugins as trusted server code. * **Standing privilege.** Prefer eligible (just-in-time) bindings over standing privileged roles, require a justification and MFA for activation, and alert on `binding:*` events. Give contractors and temporary service accounts an `expiresAt`, and schedule `purgeDeleted` so expired identities are disabled promptly. * **API keys.** Label API keys, review `credentials.list({ unusedForMs })` regularly, and revoke or rotate keys nobody uses; rotation keeps the label and resets the usage history. * **Configuration as code.** Keep tenant configuration in version control and roll it out with `config-plan` / `config-apply`; treat the applying token as an administrator credential, because every change it makes is authorized against that identity. ## Testing and disclosure [#testing-and-disclosure] Tests exercise adversarial cases but are not an independent penetration test or a security certification. Report vulnerabilities privately to the repository owner, and do not include live secrets in reports. ## Next steps [#next-steps] - [Secrets and keys](/docs/operations/deployment/secrets): What the deployment secret protects and how to rotate it. - [Tenant sign-in policy](/docs/guides/authentication/tenant-policy): MFA, allowed methods, IP ranges, and password rules per organization. - [Federation](/docs/federation): How external sign-in and provisioning are validated. # Storage adapters (/docs/operations/storage) > Choose and configure the PostgreSQL, SQLite, or libSQL/Turso adapter, move a deployment between databases with snapshots, and read what doctor reports. Better IAM stores everything through one small contract, `IamStore`, and ships three reference storage adapters that implement it on top of a shared `RecordStore` base. They behave identically: the same strict filters, the same code-point ordering, the same serialized transactions. Pick the database for its operational properties, and move between them later with a snapshot if your needs change. | Adapter | Use it for | Driver (bundled) | | ---------- | --------------------------------------------------------------------------------------------------------- | ------------------------- | | PostgreSQL | Production deployments with sustained concurrent writes or large datasets; several application instances. | `pg` | | SQLite | Single-host deployments, development, and tests. | `better-sqlite3` (native) | | libSQL | Local files, encrypted files, embedded replicas, and remote Turso or sqld databases. | `@libsql/client` | ## Configure an adapter [#configure-an-adapter] Create the adapter and pass it to `betterIam({ database })`. Each adapter takes only the settings its database needs: **PostgreSQL:** ```ts title="lib/iam.ts" import { postgresAdapter } from 'better-iam/adapter-postgres'; const database = postgresAdapter({ connectionString: process.env.DATABASE_URL!, poolSize: 10, }); ``` Transactions serialize through a transaction-scoped advisory lock, which also covers other adapter instances against the same database. Keep `synchronous_commit` on, and run `VACUUM ANALYZE iam_records` after a bulk import. `describe()` reports the server version, `synchronous_commit`, the database size, and the lock timeout. **SQLite:** ```ts title="lib/iam.ts" import { sqliteAdapter } from 'better-iam/adapter-sqlite'; const database = sqliteAdapter({ filename: './iam.db' }); ``` The adapter serializes operations per canonical filename within the process, and SQLite's own `BEGIN IMMEDIATE` lock coordinates other processes with a bounded wait. It caches prepared statements, so writes stay cheap. `describe()` reports the journal mode, synchronous level, file size, free space, and whether the database is in memory. **libSQL / Turso:** ```ts title="lib/iam.ts" import { libsqlAdapter } from 'better-iam/adapter-libsql'; const database = libsqlAdapter({ url: 'libsql://name-org.turso.io', authToken: process.env.TURSO_AUTH_TOKEN, }); ``` Transactions serialize per database within the process and take the writer lock (`BEGIN IMMEDIATE`) before any read; remote servers queue write transactions themselves. `@libsql/client` compiles each statement on every call, so local libSQL writes cost more than the SQLite adapter's. Every adapter is also available as its own package (`@better-iam/adapter-postgres`, `@better-iam/adapter-sqlite`, `@better-iam/adapter-libsql`). Durability, indexes, and upgrade windows are covered in [Database operations](/docs/operations/deployment/database). To write your own adapter, see [Adapters and plugins](/docs/operations/extensions#adapter-contract). ## Snapshots and moving between databases [#snapshots-and-moving-between-databases] Deployments often start on SQLite and outgrow it, or need a portable backup that does not depend on one database's tooling. `store-export`, `store-import`, and `store-copy` move a whole deployment between databases and adapters, for example from SQLite to PostgreSQL. They are deployment operations that work on storage directly and need no credential. **Copy directly:** ```sh better-iam store-copy --config better-iam.config.mjs --target-config target.config.mjs better-iam migrate --config target.config.mjs ``` `store-copy` copies the configured database into the empty database of `--target-config` (which must be a different configuration file) in one step: one read transaction on the source and one write transaction on the target, so the copy is consistent and all-or-nothing. **Through a file:** ```sh better-iam store-export --config better-iam.config.mjs --output snapshot.jsonl better-iam store-import --config target.config.mjs --input snapshot.jsonl better-iam migrate --config target.config.mjs ``` `store-export` writes every record to a new file (it never overwrites one, and creates it readable only by its owner): a header line, one line per record, and a trailer with counts. `store-import` migrates the schema of the configured database, which must hold no records yet, and loads the snapshot in one transaction. * **Consistency.** The export reads in one transaction, so the snapshot is consistent, but that also holds the write lock until it finishes. Schedule large exports accordingly. * **All or nothing.** A truncated or corrupt snapshot, a count that disagrees with the trailer, or a record the database refuses rolls the whole import back. * **Verbatim records.** Password hashes, sessions, encrypted secrets, and audit chains stay valid as long as the target configuration uses the same `secret`. * **After loading,** run `migrate` with the target configuration to apply plugin migrations. On PostgreSQL, follow a bulk import with `VACUUM ANALYZE iam_records`. > **Protect snapshots like the database.** A snapshot holds credential hashes and encrypted secrets. Store and transfer it with the same care as the database and its secret. The same operations are available in code as `exportStore`, `importStore`, and `copyStore` from `better-iam/core`: ```ts import { copyStore } from 'better-iam/core'; import { postgresAdapter } from 'better-iam/adapter-postgres'; import { sqliteAdapter } from 'better-iam/adapter-sqlite'; const source = sqliteAdapter({ filename: './iam.db' }); const target = postgresAdapter({ connectionString: process.env.DATABASE_URL! }); await target.migrate(); const summary = await copyStore(source, target); // { records, collections: { [name]: count } } ``` ## Doctor [#doctor] Many deployment mistakes are silent: a schema one migration behind, a placeholder secret, a scheduler that stopped weeks ago. `doctor` looks for them in one read-only pass, so you can run it after every deploy and on a schedule. `better-iam doctor` connects to the configured database and prints a JSON report with: * the Node version, whether the root is initialized, and the number of chained tenants and audit events; * the adapter's own view under `storage` (`IamStore.describe()`): schema version, applied migrations with their times, record counts per collection, and settings such as SQLite's journal mode, synchronous level, and file size, or PostgreSQL's server version and `synchronous_commit`; * `findings` from `iam.selfCheck()`, the same check your application can run in code. ```sh better-iam doctor --config better-iam.config.mjs --strict --retention-days 30 ``` * `doctor` exits 0 whenever it can connect, including to a database without the IAM schema. `--strict` exits non-zero (`DOCTOR_FINDINGS`) on any error or warning, for example as a deployment gate. * Pass the `--retention-days` your sweep uses (default 30), or `deliveryRetentionMs` and `graceMs` to `selfCheck`, so the backlog is judged the same way the sweep would. * The checks only read. `selfCheck({ cap })` bounds how many records each backlog check counts (default 1000). * Each finding has a stable `check` name, a `severity`, a `message`, and a `fix`. `ok` is false when any finding is an error. | Check | Severity | What it means | | ------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `schema-behind` | error | The database lacks a migration of this release. Run `migrate`. | | `not-bootstrapped` | error | No root tenant exists yet. | | `sqlite-durability` | error | SQLite runs a rollback journal at `durability: 'normal'`, which a power failure can corrupt. | | `unreadable-secrets` | error | Stored values open with no configured secret. See [Secrets and keys](/docs/operations/deployment/secrets). | | `in-memory-database` | warning | Everything is lost when the process exits. | | `postgres-async-commit` | warning | PostgreSQL runs with `synchronous_commit = off`. | | `weak-secret` | warning | The secret looks like a placeholder or has little variety. | | `weak-metrics-token` | warning | The metrics bearer token is shorter than 24 characters. | | `no-email-transport` | warning | No `sendEmail` transport is configured. | | `secret-rotation-pending`, `secret-rotation-unverified` | warning | A secret rotation is not finished. | | `sweep-backlog` | warning | Records have been due for the retention sweep for more than two days. | | `purge-not-running` | warning | Expired bindings, memberships, or challenges are more than a day old. | | `outbox-stalled` | warning | Outbox messages have waited more than 15 minutes. | | `outbox-abandoned` | warning | Messages were abandoned in the last day after repeated failures. | | `audit-hooks-stalled` | warning | Audit events have waited more than 15 minutes for plugins, subscribers, or `onEvent`. Dispatch them in the process that registers subscribers. | | `audit-archive-behind` | warning | A tenant has unarchived audit events older than a day. | | `previous-secrets-configured` | info | `previousSecrets` can be removed. | | `storage-undescribed` | info | The adapter cannot describe itself, so migrations were not checked. | The job-related warnings are the fastest way to notice a scheduler that stopped; see [Scheduled jobs](/docs/operations/jobs). ## Next steps [#next-steps] - [Database operations](/docs/operations/deployment/database): Migrations, durability, indexes, and backups. - [Adapters and plugins](/docs/operations/extensions#adapter-contract): Write an adapter for another database and prove it with the conformance suite. - [Scheduled jobs](/docs/operations/jobs): The workers behind the job-related doctor findings. # HTTP and configuration (/docs/guides/authentication/http) > How the HTTP handler serves authentication - routes, cookies, CSRF and Origin checks, rate limits, headers, request IDs - plus every authentication option and email template. Browsers do not call `iam.api` directly; they talk to the HTTP handler. The handler turns the API into JSON endpoints and adds what a browser-facing identity service needs: session cookies, protection against cross-site request forgery (CSRF), CORS for trusted origins, rate-limit hints, and request IDs. This page describes that behaviour, then lists every deployment option that shapes authentication. ## Mount the handler [#mount-the-handler] `iam.handler` is a Fetch-style `(request: Request) => Promise` function for runtimes and frameworks built on web standards. `iam.nodeHandler` is the same handler for Node's `http` module. Mount one of them at the base path (`/api/iam` by default). ```ts import { createServer } from 'node:http'; import { iam } from './iam'; // Node.js createServer(iam.nodeHandler).listen(3000); // Fetch-style runtimes and route handlers export const POST = (request: Request) => iam.handler(request); export const GET = (request: Request) => iam.handler(request); // health and metrics ``` The [framework integrations](/docs/frameworks) mount it for you and add typed helpers. ## Routes [#routes] The HTTP API mirrors `iam.api` one to one, so anything your server can call, a browser client can call too, subject to the same authorization. Every operation is a `POST` with a JSON body: | Route | Calls | | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `POST {basePath}/auth/{method}` | An `api.auth` method, such as `auth/signIn` or `auth/verifyMfa`. | | `POST {basePath}/{group}/{method}` | A provisioning method, such as `identities/invite`. | | `POST {basePath}/authorize`, `/authorizeMany`, `/listAccessible` | The authorization queries: one decision, a batch of up to 50, and the resources a caller may act on. | | `POST {basePath}/plugins/{pluginId}/{path}` | A plugin endpoint. | | `GET {basePath}/health` | A database check that answers only up (`200`) or down (`503`). | | `GET {basePath}/metrics` | Prometheus metrics, for the configured bearer token only. See [observability](/docs/operations/observability). | `/health` and `/metrics` are the only `GET` endpoints. Other methods fail with `METHOD_NOT_ALLOWED` (405), unknown routes with `NOT_FOUND`, and request bodies over 64 KiB with `PAYLOAD_TOO_LARGE` (413). The router maps endpoints explicitly. Routes that people use before they hold a session need no credential: the sign-in ceremonies (password, passwordless, MFA challenge, and passkey), `signUp`, email verification, password reset, email-change confirmation, and four provisioning methods (`tenants.lookup`, `domains.discover`, and the two `acceptInvitation` methods). `sts.assumeRoleWithWebIdentity` is public too, because the external OpenID Connect token it exchanges is its credential. Every other route needs a session, API key, or role token. Deployment capabilities such as `bootstrap`, `recoverRoot`, raw storage, and session-issuance primitives are never routed. ### The response envelope [#the-response-envelope] Every answer has the same shape, so one piece of client code can handle all of them. A success is `{ "data": … }`. A failure is `{ "error": { "code", "message" } }` with the error's HTTP status, plus `retryAfterMs` for rate-limited requests. Branch on `code`, never on `message`; the [error reference](/docs/reference/errors) lists every code. The typed client unwraps the envelope and throws `IamClientError` with `code`, `status`, `retryAfterMs`, and `requestId`. ## Cookies [#cookies] A session token kept in JavaScript can be stolen by any script injected into your page. The handler keeps the browser's session in an `HttpOnly` cookie instead, which scripts cannot read, so your pages never handle the token. **The session cookie** is `better-iam.session`, or `__Host-better-iam.session` over HTTPS. It is `HttpOnly`, `Secure` over HTTPS, `SameSite=Lax` (or `Strict` with `http.cookieSameSite: 'strict'`), and `Path=/`, with no parent domain. Loopback HTTP development uses the non-prefixed name. * These routes set it when they return a session: `auth/signIn`, `auth/verifyMfa`, `auth/confirmMfa`, `auth/recoverMfa`, `auth/finishPasswordless`, `auth/finishPasskeyAuthentication`, `auth/finishPasskeyMfa`, `auth/reauthenticate`, `tenants/acceptInvitation`, `identities/acceptInvitation`, and `links/switch`. * `auth/signOut` clears it, even when the sign-out itself is refused (for example because the session already lapsed or is presented from another network). * `roles/assume` and `identities/impersonate` return their token in the body only. Replacing the cookie would strand the person's own session in that browser. **Persistence.** The cookie normally lasts as long as the session (`Max-Age` equal to its remaining lifetime). A request that issues a session may send `X-Better-IAM-Persistent: 0` to receive a browser-session cookie instead, which disappears when the browser closes while the server session keeps its own lifetime. This is the console's unticked "keep me signed in". `http.persistentCookies: false` makes browser-session cookies the default, and `X-Better-IAM-Persistent: 1` opts back in. **The device cookie** `better-iam.device` (`__Host-` prefixed over HTTPS, HttpOnly) holds a ["remember this device"](/docs/guides/authentication/mfa#remember-this-device) token. A response carrying a `deviceToken` sets it, the handler injects it into later `auth/signIn` and `auth/finishPasswordless` bodies that do not carry their own, and `auth/revokeTrustedDevices` clears it. ## CSRF and Origin checks [#csrf-and-origin-checks] A browser attaches cookies to cross-site requests automatically, so a malicious page could otherwise make a signed-in person's browser call your API. The handler blocks this with three checks on every `POST`: 1. The body must be JSON (`Content-Type: application/json`) and the request must carry `X-Better-IAM: 1`. Plain HTML forms and simple cross-site requests cannot set either, so they fail with `CSRF_REJECTED`. 2. A request that carries cookies must carry an `Origin` header (`CSRF_REJECTED` otherwise). 3. Any `Origin` must exactly match a trusted origin: `baseURL`'s origin or an entry of `trustedOrigins` (exact origins only). Others fail with `UNTRUSTED_ORIGIN`. For a trusted origin, responses carry CORS headers (`Access-Control-Allow-Origin` set to that origin, credentials allowed, and `Retry-After` and `X-Request-Id` exposed), so a browser client on another trusted origin can read error codes. `OPTIONS` preflights answer `204`. A refused origin gets no CORS headers. The typed client sends the JSON content type and `X-Better-IAM: 1` on every call. Send them yourself when you call the API with `fetch` or `curl`. ## Bearer tokens [#bearer-tokens] Servers, scripts, and mobile apps usually send a token explicitly instead of relying on cookies. `Authorization: Bearer ` works for every route: session tokens, API keys, assumed-role tokens, and impersonation tokens. A bearer credential takes precedence over the cookie and never touches cookies: a bearer-authenticated request neither sets nor clears the browser's session cookie. Bearer requests without cookies need no `Origin`, but still need the JSON content type and `X-Better-IAM: 1`. ## Headers and request IDs [#headers-and-request-ids] Identity responses must never be cached by a proxy or leak through a `Referer` header. Every JSON response carries `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. Request IDs tie a browser error to your logs. Send a plain `X-Request-Id` (letters, digits, and `._:-`, at most 128 characters) and the handler echoes it on the response, success or error, and records it as `requestId` on the request's `http` observability span. The client can generate one for every call: ```ts const client = createIamClient({ requestId: true, // or () => myTraceId() onUnauthenticated: () => router.push('/login'), }); try { await client.identities.invite({ tenantId, email }); } catch (error) { if (error instanceof IamClientError) reportToSupport(error.code, error.requestId); } ``` `onUnauthenticated` fires for exactly the codes that mean the session can no longer be used, `UNAUTHENTICATED` and `SESSION_NETWORK_MISMATCH`, so you can send people to the login page in one place. Credential failures such as a wrong password never trigger it. ## Rate limits [#rate-limits] Rate limits stop password guessing, credential stuffing, and code brute-forcing. Sign-in, recovery, and MFA flows count attempts in persisted counters, so limits hold across restarts and across processes that share the database. ```ts title="iam.ts" authentication: { rateLimits: { attempts: 10, // ordinary flows such as password sign-in sensitiveAttempts: 5, // MFA, recovery, and delivery requests windowMs: 15 * 60_000, ipAttempts: 50, // per client IP across every flow of a tenant; 0 (the default) turns it off }, }, ``` * **Per account and per challenge.** Counters are kept per address or identity, and second-factor attempts are also counted per sign-in challenge and per person, so a correct password does not buy a fresh budget of code guesses. * **Per IP.** With `ipAttempts`, IPv6 clients are counted per /64 network and IPv4-mapped addresses as IPv4, so rotating addresses does not earn fresh counters. * **Passkey discovery** (sign-in without an email) is counted per client address with ten times the ordinary allowance, because login pages start one on every visit. * **Tenants tighten** the limits with their policy's `maxAttempts`; they can never raise them. * **Refusals** fail with `RATE_LIMITED` (429). The error body carries `retryAfterMs`, the response adds `Retry-After` in seconds, and `IamClientError.retryAfterMs` exposes it. With `retryRateLimited: true` the client retries once after the server's wait when it is short (five seconds by default, `{ maxWaitMs }` to change). * **Unlocking.** `identities.unlock` clears one person's counters; it never clears a network's counter. `createMemoryRateLimiter()` from `better-iam/auth` keeps counters in process memory for single-process deployments and tests. Network-level controls still belong at your ingress: apply request and body limits there too, especially to the public lookups. ## Client details [#client-details] Sessions record the client they were issued to (IP, user agent, and a device label), and several features judge the IP: tenant allowlists, session binding, network blocks, per-IP rate limits, and new sign-in notices. By default the handler records only the `User-Agent` header, because a client IP is only trustworthy when your own proxy sets it. Supply `http.clientInfo` to read it: ```ts title="iam.ts" http: { clientInfo: (request) => ({ // Only trust a header your own proxy or load balancer sets and overwrites. ip: request.headers.get('x-real-ip') ?? undefined, userAgent: request.headers.get('user-agent') ?? undefined, label: request.headers.get('x-device-name') ?? undefined, }), }, ``` Values are trimmed and bounded, and nothing about the client is ever trusted for authorization. Framework integrations that pass the incoming request's headers as the credential derive the same client details, so server actions and route handlers are judged like direct HTTP calls. For sessions you create with direct `iam.api.auth.*` calls, wrap the call in `iam.auth.withClient(info, fn)`. > **Never trust X-Forwarded-For blindly.** Anyone can send an `X-Forwarded-For` header. Read the client IP only from a header your own proxy sets, or an attacker can choose the address your allowlists and blocks judge. ## Configuration reference [#configuration-reference] Everything on the authentication pages is configured in three places of the `betterIam(options)` object: a few top-level options, the `authentication` block, and the `http` block. This reference lists them together. ### Top-level options [#top-level-options] | Option | Purpose | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `baseURL` | Where the service is reached. HTTPS is required outside `localhost`, `127.0.0.1`, and `[::1]`. Its origin is always trusted. | | `basePath` | Where the handler serves the API (`/api/iam` by default). | | `trustedOrigins` | Additional exact origins allowed to call the API with cookies, and to host passkey ceremonies. | | `secret` | The deployment secret (at least 32 characters). It seals MFA secrets and deliveries and keys token digests. | | `previousSecrets` | Up to five secrets being rotated out; see [secrets](/docs/operations/deployment/secrets). | ### `authentication` [#authentication] ### `http` [#http] ## Email and SMS templates [#email-and-sms-templates] Better IAM never sends mail itself, so you keep your own provider, sender domain, and branding. It queues a message in the delivery outbox with a `template` name and a `payload`, and your `sendEmail` or `sendSms` callback renders and delivers it. | Template | Channel | Sent for | Payload | | ------------------- | ------------------- | ------------------------------------------------------ | --------------------------------------------------------- | | `verify-email` | email | Sign-up and `requestEmailVerification` | `token` | | `password-reset` | email | `requestPasswordReset` and administrator resets | `token` | | `email-change` | email (new address) | `requestEmailChange` | `token` | | `magic-link` | email | `startPasswordless` with `kind: 'magic-link'` | `token` | | `code` | email or SMS | `startPasswordless` with `kind: 'code'` | `token` (six digits) | | `phone-verify` | SMS | `startPhoneVerification` | `token` (six digits) | | `mfa-code` | email | `requestMfaCode` | `code` | | `new-sign-in` | email | A session from an unfamiliar client | `sessionId`, `time`, `method`, `userAgent`, `ip`, `label` | | `sign-in-failures` | email | The failed-attempt streak reached `failedSignInAlerts` | `attempts`, `time`, `ip`, `userAgent` | | `owner-invitation` | email | `tenants.create` | `token`, `tenantId`, `tenantName` | | `member-invitation` | email | `identities.invite` | `token`, `tenantId`, `tenantName`, `inviterName` | The governance features add `certification-review` and `certification-reminder`. Payloads are sealed in the outbox and handed to your callback decrypted, and the message's top-level `tenantId` names the tenant. `renderDeliveryMessage(message, { appName, links })` from `better-iam/auth/templates` turns any of the built-in templates into `{ subject, text, html }`, with HTML escaping. The subpath has no native dependencies, so it also suits email workers and edge runtimes. You supply link builders for your own pages; a missing builder falls back to the raw token, and templates it does not know (such as `phone-verify`, plugin templates, or newer features) return `undefined` so your callback can render them itself. ```ts title="iam.ts" import { renderDeliveryMessage } from 'better-iam/auth/templates'; const origin = 'https://app.example.com'; authentication: { sendEmail: async (message) => { const tenant = message.tenantId; const rendered = renderDeliveryMessage(message, { appName: 'Acme Cloud', links: { invitation: ({ kind, tenantId, token }) => `${origin}/join?kind=${kind}&tenant=${tenantId}&token=${token}`, passwordReset: ({ token }) => `${origin}/reset?tenant=${tenant}&token=${token}`, verifyEmail: ({ token }) => `${origin}/verify?tenant=${tenant}&token=${token}`, emailChange: ({ token }) => `${origin}/email?tenant=${tenant}&token=${token}`, magicLink: ({ token, destination }) => `${origin}/magic?tenant=${tenant}&token=${token}&to=${encodeURIComponent(destination)}`, account: ({ tenantId }) => `${origin}/${tenantId}/account`, // "Review your account" on security notices }, }); if (!rendered) throw new Error(`Unknown template ${message.template}`); // the outbox retries later await mailer.send({ to: message.to, ...rendered }); }, }, ``` Delivery is at least once and happens outside the write transaction, after commit. Deduplicate by `message.id`, and never log tokens or payloads. See [data and consistency](/docs/guides/concepts/data-and-consistency#side-effects-leave-through-the-outbox). # Impersonation (/docs/guides/authentication/impersonation) > Audited "view as" sessions that let support staff see exactly what a member sees, restricted to what both people may do and visible everywhere. Support teams often need to see what a customer sees: a missing button, a permission that does not apply, a page that fails for one person. Asking for their password is unsafe, and granting yourself their roles changes their access. Impersonation solves this with a short-lived session that acts as the member, while every action stays attributed to the administrator behind it. Impersonation is off by default. It is designed to be hard to misuse: it is opt-in per organization, needs a recorded reason, cannot do anything sensitive, never exceeds the administrator's own rights, and is visible to the member, to policies, and in every audit record. ## Enable it for a tenant [#enable-it-for-a-tenant] An organization turns impersonation on in its [authentication policy](/docs/guides/authentication/tenant-policy), and grants `iam:identities:impersonate` to the people allowed to use it: ```ts await iam.api.tenants.setAuthPolicy(ownerCredential, { tenantId, authPolicy: { ...currentPolicy, allowImpersonation: true }, }); ``` `setAuthPolicy` replaces the whole policy, so carry the existing fields over. Without `allowImpersonation`, every impersonation attempt in the tenant fails with `FEATURE_DISABLED`. ## Start a "view as" session [#start-a-view-as-session] `identities.impersonate` opens a session as a member and returns its token. Call it from your support tooling when an administrator chooses a member and states why: ```ts const { token, session, identity } = await iam.api.identities.impersonate(adminCredential, { tenantId, identityId: memberId, reason: 'Ticket 4821: export button missing', durationMs: 30 * 60_000, // one minute to eight hours; one hour by default }); ``` The call succeeds only when all of these hold: * the administrator holds `iam:identities:impersonate` on the member and authenticated recently; * the administrator acts through an **ordinary session of their own**, not an impersonation or assumed-role session (`IMPERSONATION_RESTRICTED`); * a `reason` of up to 512 characters is given; * the member is an active person who is neither the administrator, an owner, nor a root administrator (owners and root administrators fail with `ACCESS_DENIED`; service accounts cannot be impersonated); * if the member requires MFA, the administrator's own session passed MFA (`MFA_REQUIRED`); * the tenant's IP allowlist and network blocks allow the administrator's address. Each impersonation is audited as `identity:impersonate` with the reason, the new session ID, and its expiry. > **The token is returned in the body only.** The HTTP handler never sets the impersonation token as a cookie. Replacing the administrator's own session cookie would strand their real session in that browser. Keep the token in memory in a separate context, such as a dedicated tab or window, and send it as a bearer token. ```ts title="support/view-as.ts" import { createIamClient } from 'better-iam/client'; import type { iam } from './iam'; // A client that acts as the member for as long as the token lives. const viewAs = createIamClient({ token: () => impersonationToken }); const { results } = await viewAs.authorizeMany({ tenantId, checks }); ``` ## What the session can do [#what-the-session-can-do] An impersonation session acts as the member, with the method `impersonation`: requests made with its token are evaluated as the member's principal. Every authorization decision made through it is allowed only when **both** the member and the impersonating administrator may perform the action. Support staff therefore cannot use a more privileged member's session to act, or to grant themselves lasting access, beyond their own role. Reverse queries (`listAccessible`) list only what both could reach. An action the member could perform but the administrator could not is refused like any other denial (`ACCESS_DENIED`). It **cannot**: * perform anything that requires recent authentication: password, email, factor, device, session, and ownership changes (`IMPERSONATION_RESTRICTED`); * re-authenticate, so it can never become recent; * assume roles, grant OAuth consent, or impersonate anyone else. It inherits the administrator's MFA state (`principal.mfa`) but carries no fresh factor time (`principal.mfaTime` is absent), and it does not count toward the member's `maxSessions` cap. What an administrator does through it is not recorded as the member's own access usage. ## How long it lasts [#how-long-it-lasts] The session lasts `durationMs` (one hour by default, eight hours at most) and never beyond the administrator's own session. It ends the moment: * its duration passes or the administrator's session expires; * the administrator signs out, or their session is revoked; * the administrator is disabled or deleted; * the session itself signs out, or is revoked like any other session. ## Who can see it [#who-can-see-it] Impersonation is never hidden: | Where | What it shows | | ------------- | ---------------------------------------------------------------------------------------------------------------------- | | Audit records | Every record the session produces carries `impersonatorId` beside the member's `actorId`. Webhook events carry it too. | | Policies | `principal.impersonated` is `true` and `principal.impersonatorId` names the administrator. | | Assertions | Signed caller tokens for your other services, issued with `assertions.issue`, carry `impersonatorId`. | | The member | The session appears in their own `auth.listSessions`, and `auth.listSecurityEvents` names the administrator. | Policies can use this to keep certain actions out of reach of support entirely, whatever the member's roles allow. An explicit deny like this one overrides every allow: ```ts { effect: 'deny', actions: ['billing:*'], resources: ['*'], conditions: { Bool: { 'principal.impersonated': true } }, } ``` The framework integrations recognize impersonation too: a page that demands a fresh second factor or recent authentication redirects an impersonation session to your step-up page with `reason=impersonation`. See [Next.js guards](/docs/frameworks/nextjs/guards). # Authentication (/docs/guides/authentication) > How people and services prove who they are in Better IAM, how a sign-in flows from first factor to session, and where each method is configured. Before your application can decide what someone may do, it has to know who they are. Authentication covers how people and services prove their identity, what the resulting session carries, and which knobs the deployment and each organization control. Every sign-in happens **in a [tenant](/docs/guides/concepts#key-terms)**, an organization's isolated account: the same email in two tenants is two separate identities with separate credentials, factors, and sessions. The [security model](/docs/operations/security) explains the guarantees; these pages explain the flows. ## Methods at a glance [#methods-at-a-glance] Different people and situations call for different proofs: a password for most people, a passkey for phishing-resistant sign-in, a company identity provider for enterprise customers, and API keys for machines. You enable the methods your product needs, and each organization can narrow the list. | Method | Calls | Session `method` | Notes | | ---------------------------- | ------------------------------------------------------------------------------------------ | -------------------- | -------------------------------------------------------------------------------------------- | | Password | `auth.signIn({ tenantId, email, password })` | `password` | Argon2id; tenant password rules apply on creation, reset, and change. | | Magic link or emailed code | `auth.startPasswordless`, then `auth.finishPasswordless` | `passwordless-email` | Needs `passwordlessEmail` and `sendEmail`. Never links accounts by matching an address. | | SMS code | `auth.startPasswordless`, then `auth.finishPasswordless` | `passwordless-sms` | Needs `passwordlessSms` and `sendSms`, and a verified phone number. | | Passkey | `auth.beginPasskeyAuthentication`, then `auth.finishPasskeyAuthentication` | `passkey` | Discoverable passkeys sign in without an email (browser autofill). Satisfies MFA. | | Federated (OAuth/OIDC, SAML) | The protocol packages complete the ceremony and call `protocolHost.completeAuthentication` | `federated` | `mapAttributes` stores provider attributes on the identity. | | API key (service accounts) | `Authorization: Bearer ` | none | Issued with `credentials.create`; expiring, rotatable, labeled, with `lastUsedAt`. | | Assumed role | `roles.assume({ tenantId, trustId })` | none | Platform-controlled trust; sessions are short and cannot chain. | | Impersonation ("view as") | `identities.impersonate` | `impersonation` | An administrator's session as a member; opt-in per tenant, restricted, and fully attributed. | The session's `method` reaches policies as `principal.authMethod`, so a policy can, for example, require a passkey for a sensitive action. [Sign-in methods](/docs/guides/authentication/sign-in-methods) covers each method in detail. ## How a sign-in works [#how-a-sign-in-works] Whatever the method, a sign-in follows the same shape, so your login UI only has to handle two outcomes. The first factor (a password, a code, a passkey, or a federated assertion) ends either with a **session**, or with a **challenge** that asks for a second factor. Every sign-in returns one of two shapes: ```ts title="SignInResult" type SignInResult = | { token: string; session: SafeSession } | { mfaRequired: true; challenge: string; // single-use, valid for five minutes enrollmentRequired: boolean; // true when no authenticator is enrolled yet emailCodeAvailable?: boolean; // requestMfaCode may email a one-time code passkeyAvailable?: boolean; // a registered passkey may satisfy the challenge }; ``` A typical login page first finds the tenant with `tenants.lookup` (from an organization alias), then calls `auth.signIn`, which checks the password and returns one of those two shapes. Through the HTTP handler, a returned session is also set as a cookie, so the page only needs to navigate on. ```ts title="login.ts" import { createIamClient, IamClientError } from 'better-iam/client'; import type { iam } from './iam'; const client = createIamClient(); const { tenantId } = await client.tenants.lookup({ slug: 'acme' }); try { const result = await client.auth.signIn({ tenantId, email, password }); if ('mfaRequired' in result) { // Show the second-factor step; see the MFA guide. return goToSecondFactor(tenantId, result); } // Signed in: the handler set the session cookie. } catch (error) { if (error instanceof IamClientError && error.code === 'RATE_LIMITED') { showRetryIn(error.retryAfterMs); } else throw error; } ``` ### What every sign-in checks [#what-every-sign-in-checks] In order, before any session is issued: 1. **Network blocks** refuse a blocked client address with `IP_BLOCKED`, before rate limits or credentials are examined. 2. **Rate limits** count the attempt against the client IP (when `ipAttempts` is set) and the account, and refuse with `RATE_LIMITED`. 3. **The tenant** and every ancestor must be active (`TENANT_UNAVAILABLE`). 4. **The method** must be in the tenant's `allowedMethods` (`METHOD_NOT_ALLOWED`). This happens before any credential is examined, so a rejected method never reveals whether a password was right. 5. **The credential** is verified. Unknown addresses perform the same password-hash work as real ones and return the same `INVALID_CREDENTIALS`, so responses do not reveal which accounts exist. 6. **Email verification** is required when `requireEmailVerification` is on (`EMAIL_UNVERIFIED`), and an expired password is refused with `PASSWORD_EXPIRED` only after it was verified. 7. **MFA**, when required, returns a challenge instead of a session. 8. **Session issuance** re-checks the tenant's IP allowlist (`IP_NOT_ALLOWED`) and blocks, applies the tenant's session lifetime, ends the oldest session beyond `maxSessions`, records the client, and may queue a new-sign-in notice. After issuance, the session is re-validated on every use. See [sessions](/docs/guides/authentication/sessions). ## When MFA is required [#when-mfa-is-required] A password alone falls to phishing, reuse, and guessing. Multi-factor authentication (MFA) adds a second proof, such as an authenticator code or a passkey. MFA is required: * for root administrators, always; * for anyone with an authenticator enrolled; * when the tenant policy sets `requireMfa` (or `requireMfaForOwners`, for owners); * when the deployment's `authentication.requireMfa(tenant, identity)` callback returns true. The requirement also applies to federated sign-in and to sessions that already exist: requiring MFA in a tenant locks out sessions that did not complete it on their next use. See [multi-factor authentication](/docs/guides/authentication/mfa). ## Recent authentication [#recent-authentication] A session can live for days, and a laptop left unlocked for a minute should not let someone change the password or remove the second factor. So sensitive operations (password, email, factor, device, session, and ownership changes, impersonation, and policy changes) require **recent authentication**: the session must have been established within `recentAuthenticationMs` (five minutes by default). Otherwise they fail with `RECENT_AUTH_REQUIRED`, and the person confirms their password with `auth.reauthenticate({ password })`, which issues a fresh session. See [sessions](/docs/guides/authentication/sessions#recent-authentication). ## In this section [#in-this-section] - [Sign-in methods](/docs/guides/authentication/sign-in-methods): Passwords and password screening, magic links, email and SMS codes, federation, API keys, and assumed roles. - [Multi-factor authentication](/docs/guides/authentication/mfa): TOTP, recovery codes, emailed codes, remembered devices, and step-up. - [Passkeys](/docs/guides/authentication/passkeys): Passkeys for sign-in and as a second factor, discoverable sign-in, and naming. - [Sessions](/docs/guides/authentication/sessions): Lifetimes, idle timeouts, device metadata, sign-out everywhere, and sign-in records. - [Verification and recovery](/docs/guides/authentication/recovery): Email verification, password reset and change, email changes, and lockouts. - [Tenant policy](/docs/guides/authentication/tenant-policy): Per-organization MFA, methods, password rules, session limits, and network restrictions. - [Impersonation](/docs/guides/authentication/impersonation): Audited "view as" sessions for support. - [HTTP and configuration](/docs/guides/authentication/http): Cookies, CSRF and Origin checks, rate limits, request IDs, deployment options, and email templates. # Multi-factor authentication (/docs/guides/authentication/mfa) > When a second factor is required, how people complete it with TOTP, recovery codes, emailed codes, or passkeys, and how remembered devices and step-up work. Passwords get phished, reused across sites, and guessed. Multi-factor authentication (MFA) asks for a second, independent proof, so a stolen password or a compromised mailbox alone is not enough to take over an account. Better IAM supports authenticator apps (TOTP) with recovery codes, [passkeys](/docs/guides/authentication/passkeys), and, where an organization allows it, one-time codes sent to a verified email address. A browser that completed MFA can be remembered for a while so people are not asked on every sign-in. ## When MFA is required [#when-mfa-is-required] You rarely switch MFA on per person; rules make it required. MFA is required when any of these holds: * the person is a **root administrator** (always, and never through a remembered device or an emailed code); * the person has an **authenticator enrolled**; * the tenant tenant.type === 'organization' && identity.owner, }, ``` The requirement applies to password, passwordless, and federated sign-in alike, and to sessions that already exist: they are re-validated on every use, so requiring MFA in a tenant locks out sessions that did not complete it (`MFA_REQUIRED`) on their next request. A person cannot disable MFA while their tenant or the root role requires it. Resetting a password does not remove MFA. ## Completing a challenge [#completing-a-challenge] After a correct password (or magic link, or federated sign-in), a person who needs MFA is not signed in yet. The first factor returns a **challenge**, a short-lived ticket that proves the first step succeeded, instead of a session. Your UI then asks for the second factor and sends it with the challenge: ```ts title="MfaRequired" type MfaRequired = { mfaRequired: true; challenge: string; enrollmentRequired: boolean; // no authenticator enrolled yet emailCodeAvailable?: boolean; // requestMfaCode may email a code passkeyAvailable?: boolean; // a registered passkey may answer }; ``` The challenge is single-use and valid for five minutes. What the person does next depends on the flags: **Authenticator:** `enrollmentRequired: false`: the person has an authenticator app. They enter its current code, and `auth.verifyMfa` checks it against the challenge and issues the session. ```ts const session = await client.auth.verifyMfa({ tenantId, challenge: result.challenge, code: '123456', rememberDevice: true, // optional; see below }); ``` **Enroll:** `enrollmentRequired: true`: MFA is required but nothing is enrolled yet, so the person sets up an authenticator now. `auth.beginMfa` creates a new secret and returns it with an `otpauth://` URI to show as a QR code. `auth.confirmMfa` checks the first code from the app, enables the factor, returns ten single-use recovery codes, and issues the session. ```ts const { secret, uri } = await client.auth.beginMfa({ tenantId, challenge: result.challenge }); renderQrCode(uri); const { recoveryCodes, ...session } = await client.auth.confirmMfa({ credential: { tenantId, challenge: result.challenge }, code: '123456', }); showRecoveryCodesOnce(recoveryCodes); ``` **Emailed code:** `emailCodeAvailable: true`: the tenant (`mfaEmailCodes`) or the deployment (`authentication.mfaEmailCodes`) lets people without an authenticator use a code emailed to their verified address. `auth.requestMfaCode` sends the code, and `auth.verifyMfa` accepts it like an authenticator code. ```ts const { expiresAt } = await client.auth.requestMfaCode({ tenantId, challenge: result.challenge }); const session = await client.auth.verifyMfa({ tenantId, challenge: result.challenge, code: '123456' }); ``` **Passkey:** `passkeyAvailable: true`: the person has a registered passkey. `auth.beginPasskeyMfa` returns WebAuthn options bound to this challenge, the browser asks the authenticator to sign them, and `auth.finishPasskeyMfa` verifies the result and issues the session. ```ts import { startAuthentication } from 'better-iam/client/passkeys'; const { challengeId, options } = await client.auth.beginPasskeyMfa({ tenantId, challenge: result.challenge }); const response = await startAuthentication({ optionsJSON: options }); const session = await client.auth.finishPasskeyMfa({ tenantId, challengeId, response, rememberDevice: true }); ``` **Recovery code:** The person lost their authenticator but kept a recovery code. `auth.recoverMfa` accepts one in place of the authenticator code and issues the session. Each code works once. ```ts const session = await client.auth.recoverMfa({ tenantId, challenge: result.challenge, code: recoveryCode }); ``` A wrong authenticator code, emailed code, or recovery code for a real account is recorded as a failed attempt (`auth:signin:fail` with `reason: 'mfa'` or `'recovery-code'`). Second-factor attempts are rate limited on the sensitive tier both per challenge and per person, so a correct password does not buy a fresh budget of code guesses. ## Authenticator apps (TOTP) [#authenticator-apps-totp] An authenticator app on a phone or in a password manager generates a six-digit code that changes every 30 seconds (TOTP, time-based one-time passwords). It works offline, costs nothing to send, and is the factor most people already know. * `auth.beginMfa` generates a secret and an `otpauth://` URI labeled with the person's email and your `appName`. The secret is stored with authenticated encryption. * Enrollment must be confirmed within ten minutes, with the same credential that started it: the sign-in challenge, or a recently authenticated session. * Codes are six digits on 30-second steps, and a code from the adjacent step is accepted to absorb clock drift. Each time step is accepted **once**, so a captured code cannot be replayed. * A person who already has an authenticator gets `MFA_ALREADY_ENABLED` from `beginMfa`, and cannot start a new enrollment from a sign-in challenge (`MFA_REQUIRED`): they must use their existing factor first. People who are not required to use MFA can still turn it on from their account page. They enroll from a signed-in session, which needs recent authentication: ```ts const { uri } = await client.auth.beginMfa(); // no challenge: the session is the credential const { recoveryCodes } = await client.auth.confirmMfa({ code: '123456' }); ``` Confirming an enrollment ends every existing session of the person and returns a new, MFA-verified one. Through the HTTP handler the new session replaces the cookie. ## Recovery codes [#recovery-codes] Phones get lost and reset. Recovery codes are the way back in without an administrator: enrollment returns ten single-use codes, which the person should store somewhere safe, such as a password manager. They are stored hashed and consumed on use. * `auth.recoverMfa({ tenantId, challenge, code })` completes a sign-in challenge with one (audited as `auth:mfa:recover`). * `auth.regenerateRecoveryCodes()` replaces the whole set. It needs recent authentication and an MFA-verified session (audited as `auth:mfa:recovery-codes`). * `auth.mfaStatus()` reports `recoveryCodesRemaining`, so your account page can prompt people to regenerate before they run out. ## Emailed one-time codes [#emailed-one-time-codes] Emailed codes let an organization require MFA without forcing every member to install an authenticator. They are off by default; turn them on per tenant with `mfaEmailCodes` or for the deployment with `authentication.mfaEmailCodes` (the tenant setting wins). * A sign-in offers them (`emailCodeAvailable: true`) only to people with **nothing enrolled** and a **verified** email address, only when `sendEmail` is configured, and **never** to root administrators. * `auth.requestMfaCode` emails a six-digit code (template `mfa-code`) bound to that challenge. It is hashed at rest, single-use, and valid for at most ten minutes and never beyond the challenge itself. Requesting again replaces the code, and requests are rate limited per challenge and per person so repeated sign-ins cannot flood a mailbox. * A challenge that did not offer codes fails with `FEATURE_DISABLED`. > **Weaker than an authenticator.** A code rides on the mailbox, so it is weaker than an authenticator. People with an authenticator enrolled, and root administrators always, must use the authenticator or a recovery code. ## Passkeys as a second factor [#passkeys-as-a-second-factor] Whenever a person has a passkey registered, the challenge carries `passkeyAvailable: true` and they can complete MFA with it instead of typing a code. The assertion is verified like a passkey sign-in and bound to the pending sign-in challenge, which must still be open and belong to the same person; both challenges are consumed together. A passkey sign-in on its own already satisfies MFA. See [passkeys](/docs/guides/authentication/passkeys). ## Remember this device [#remember-this-device] Asking for a code every time someone signs in from the same laptop is tedious, and tedium pushes organizations to turn MFA off. "Remember this device" lets a browser that recently completed MFA skip the second factor for a while, while a new device, or a stolen password used elsewhere, still has to pass it. `rememberDevice: true` on `verifyMfa`, `confirmMfa`, or `finishPasskeyMfa` asks Better IAM to remember the browser. When the deployment and the tenant allow it, the response carries a `deviceToken` and `deviceExpiresAt`. Passing that token to `signIn` or `finishPasswordless` later satisfies the MFA requirement without a code. * **Lifetime.** The shorter of `authentication.trustedDeviceLifetimeMs` (30 days by default, one year at most, `0` disables the feature) and the tenant's `trustedDeviceDays` (`0` turns it off for the tenant). * **Storage.** The token is opaque and stored hashed. Through the HTTP handler it lives in its own HttpOnly `better-iam.device` cookie (`__Host-` prefixed over HTTPS), which the handler injects into later `signIn` and `finishPasswordless` bodies, so the client only has to send `rememberDevice: true` once. * **Root administrators** are never remembered. * **Invalid or expired tokens** simply lead to the normal challenge. * **Sessions** established this way carry `trustedDeviceId`. They count as MFA-verified (`principal.mfa`) but carry no fresh factor time (`principal.mfaTime` is absent). People manage their remembered devices from their account page: * `auth.listTrustedDevices` lists the live ones, newest use first, with the client details, `createdAt`, `lastUsedAt`, and `expiresAt`, so people can recognize each browser. * `auth.revokeTrustedDevice({ deviceId })` forgets one device, for example a shared computer. The next sign-in from it asks for MFA again. Needs recent authentication. * `auth.revokeTrustedDevices()` forgets all of them, and through the HTTP handler also clears the device cookie. Needs recent authentication. ```ts const devices = await client.auth.listTrustedDevices(); await client.auth.revokeTrustedDevice({ deviceId: devices[0].id }); ``` Every remembered device is forgotten when the person changes or resets their password, changes their email, enrolls or disables an authenticator, or removes a passkey, when an administrator revokes their sessions or everyone's in the tenant, and on `revokeTrustedDevices`. Remembering and forgetting are audited as `auth:device:trust` and `auth:device:revoke`. ## Managing factors [#managing-factors] An account page usually shows what a person has set up and lets them change it. These calls work from a signed-in session: | Call | Needs | Effect | | ---------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `auth.mfaStatus()` | a session | Summarizes the person's setup for an account page: `enabled`, `recoveryCodesRemaining`, `passkeys`, `trustedDevices`, and `sessionMfa` (whether this session passed MFA). | | `auth.beginMfa()`, `auth.confirmMfa({ code })` | recent authentication | Enrolls an authenticator (audited as `auth:mfa:enable`). | | `auth.regenerateRecoveryCodes()` | recent authentication, MFA session | Replaces the recovery codes, when they run low or may have been seen. | | `auth.disableMfa()` | recent authentication, MFA session | Removes the authenticator and ends every session (audited as `auth:mfa:disable`). Refused with `MFA_REQUIRED` for root administrators and when the tenant requires MFA. | ## Step-up [#step-up] Some actions deserve more than a valid session: refunding a payment, deleting a project, changing who is an owner. Step-up authentication asks the person to prove themselves again, or with MFA, right before such an action. Better IAM gives you three building blocks: * **Recent authentication.** Sensitive operations require a session established within `recentAuthenticationMs` (five minutes by default) and fail with `RECENT_AUTH_REQUIRED` otherwise. `auth.reauthenticate({ password })` re-runs the password ceremony, and the second factor when the account has one, and issues a fresh session. See [recent authentication](/docs/guides/authentication/sessions#recent-authentication). * **Policy conditions.** Policies can read facts about the principal (the caller): `principal.mfa` (the session passed MFA), `principal.mfaTime` (when a first-hand factor was last verified; absent for remembered-device, impersonation, and API-key sessions), and `principal.authMethod`. A policy can demand MFA for one action only: ```ts { effect: 'allow', actions: ['billing:refund'], resources: ['invoice/*'], conditions: { Bool: { 'principal.mfa': true } }, } ``` * **Framework guards.** The framework integrations accept a `stepUp` requirement (`mfa: true`, `mfa: 'fresh'`, or `maxAgeMs`) on pages, routes, and actions, and redirect to your `stepUpPath` with a reason. See [Next.js guards](/docs/frameworks/nextjs/guards). Just-in-time elevation can also require an MFA-verified session for every activation of an administrator role; see [elevation](/docs/guides/privileged-access/elevation). # Passkeys (/docs/guides/authentication/passkeys) > Register passkeys, sign in with them (including discoverable autofill sign-in), use them as a second factor, and let people name and manage them. Passwords and one-time codes can be typed into a convincing fake login page. Passkeys cannot: they are WebAuthn credentials, a key pair kept on a device or synced by a platform account, and the browser only uses them on your own site. They also verify the person on the device (with biometrics or a PIN), so one passkey is both factors at once. In Better IAM a passkey can sign a person in on its own, and satisfies MFA when it does, or it can answer the second step of a password or passwordless sign-in. ## Configure [#configure] WebAuthn ties every passkey to a **relying party**: the site it may be used on, identified by a domain (the RP ID). Set it under `authentication.passkeys`: ```ts title="iam.ts" export const iam = betterIam({ baseURL: 'https://app.example.com', trustedOrigins: ['https://admin.example.com'], authentication: { passkeys: { rpID: 'example.com', rpName: 'Acme Cloud' }, }, // ... }); ``` * `rpID` must match every trusted origin: each origin's hostname must equal it or be a subdomain of it. Construction fails with `INVALID_CONFIG` otherwise. * `rpName` is the name authenticators show; it defaults to `authentication.appName` ("Better IAM" by default). * Without `passkeys`, every passkey call fails with `FEATURE_DISABLED`. In the browser, import the WebAuthn helpers from `better-iam/client/passkeys`, a separate entry point so the core client does not bundle them: | Helper | What it does | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `startRegistration` | Asks the authenticator to create a passkey from server-issued registration options. | | `startAuthentication` | Asks the authenticator to sign server-issued options with an existing passkey; `useBrowserAutofill: true` offers passkeys in the username field. | | `browserSupportsWebAuthn` | Whether the browser supports passkeys at all, so you can hide the button when it does not. | | `browserSupportsWebAuthnAutofill` | Whether the browser can offer passkeys through autofill. | | `platformAuthenticatorIsAvailable` | Whether the device has a built-in authenticator (such as a fingerprint reader). | | `WebAuthnAbortService` | Cancels a waiting ceremony, for example an autofill request you want to restart. | ## Register a passkey [#register-a-passkey] People add passkeys from their account page, while signed in with a recently authenticated session. Recent authentication stops someone who finds an unlocked laptop from adding their own passkey to the account. ### Get registration options [#get-registration-options] `auth.beginPasskeyRegistration` creates a one-time challenge and the WebAuthn options that describe the passkey to create. ```ts import { startRegistration } from 'better-iam/client/passkeys'; const { challengeId, options } = await client.auth.beginPasskeyRegistration(); ``` The options require a discoverable credential (resident key) and user verification, request no attestation, and exclude the passkeys the person already has. The challenge is bound to this session and valid for five minutes. ### Run the browser ceremony [#run-the-browser-ceremony] The browser shows its passkey dialog, the person confirms with their fingerprint, face, or PIN, and the authenticator creates the key pair. ```ts const response = await startRegistration({ optionsJSON: options }); ``` ### Finish and name it [#finish-and-name-it] `auth.finishPasskeyRegistration` sends the authenticator's response back, with an optional name the person will recognize later in their list. ```ts const { id, name } = await client.auth.finishPasskeyRegistration({ challengeId, response, name: 'Work laptop', // optional, at most 64 characters }); ``` The server verifies the challenge, origin, RP ID, and user verification, then stores the public key. Without a name, the passkey is labeled from what the authenticator reports about itself: "This device" for a built-in authenticator, "Phone" for a cross-device (hybrid) one, "Security key" for USB, NFC, or Bluetooth keys, and "Passkey" otherwise. Registration is audited as `auth:passkey:create` with the name. A credential is unique for the relying party across the whole installation, including across tenant accounts: registering the same credential twice fails with `PASSKEY_EXISTS`. ## Sign in with a passkey [#sign-in-with-a-passkey] Signing in with a passkey takes two calls around the browser ceremony. `auth.beginPasskeyAuthentication` issues a challenge and the options for the browser, and `auth.finishPasskeyAuthentication` verifies the signed response and issues a session that has already passed MFA (`method: 'passkey'`). The tenant's `allowedMethods` must include `passkey`. **Discoverable (autofill):** Without an `email`, the options name no credential. The authenticator offers whatever discoverable passkey it holds for your relying party, through the browser's passkey picker or autofill, and the server finds the account from the credential itself. ```tsx // on the login form enables autofill. import { browserSupportsWebAuthnAutofill, startAuthentication } from 'better-iam/client/passkeys'; if (await browserSupportsWebAuthnAutofill()) { const { challengeId, options } = await client.auth.beginPasskeyAuthentication({ tenantId }); const response = await startAuthentication({ optionsJSON: options, useBrowserAutofill: true }); await client.auth.finishPasskeyAuthentication({ tenantId, challengeId, response }); } ``` **With an email:** When your login form asks for the email first, pass it along: the options then list that person's passkeys, so the browser only offers theirs. ```ts import { startAuthentication } from 'better-iam/client/passkeys'; const { challengeId, options } = await client.auth.beginPasskeyAuthentication({ tenantId, email: 'alice@example.com', }); const response = await startAuthentication({ optionsJSON: options }); await client.auth.finishPasskeyAuthentication({ tenantId, challengeId, response }); ``` An unknown or inactive address fails with `INVALID_CREDENTIALS`. * The sign-in challenge is valid for five minutes. An autofill request can wait longer than that, so start a fresh one before it expires (the console refreshes after four minutes). * Discovery names nobody, so it is rate limited per client address, with ten times the ordinary allowance, because login pages start one on every visit. * A discovered passkey that is not registered in this organization, a user handle that does not match the account, or a failed verification is refused with `INVALID_PASSKEY`. * The server checks the challenge, origin, RP ID, user verification, the signature, and the signature counter, and records the passkey's `lastUsedAt`. ## Passkeys as a second factor [#passkeys-as-a-second-factor] People who still sign in with a password can use their passkey as the second step, which is faster than typing a code and cannot be phished. When a password or passwordless sign-in returns `mfaRequired` with `passkeyAvailable: true` (the person has at least one passkey), `auth.beginPasskeyMfa` issues WebAuthn options bound to that sign-in challenge, and `auth.finishPasskeyMfa` verifies the response and issues the session: ```ts const { challengeId, options } = await client.auth.beginPasskeyMfa({ tenantId, challenge: result.challenge }); const response = await startAuthentication({ optionsJSON: options }); const session = await client.auth.finishPasskeyMfa({ tenantId, challengeId, response, rememberDevice: true, // optional; see "remember this device" }); ``` The assertion is verified like a passkey sign-in (challenge, origin, RP ID, user verification, signature, counter) and bound to the pending sign-in challenge, which must still be open and belong to the same person (`INVALID_CHALLENGE` otherwise). Both challenges are consumed together. The session keeps the method of the first factor, such as `password`. Without a registered passkey, `beginPasskeyMfa` fails with `FEATURE_DISABLED`. ## Manage passkeys [#manage-passkeys] People collect passkeys on several devices over time, and need to recognize and remove old ones, such as the passkey on a phone they sold. Three calls back an account page: * `auth.listPasskeys` returns the caller's passkeys, newest first, without key material (fields below). * `auth.renamePasskey` gives a passkey a label the person recognizes, such as "YubiKey 5C". Audited as `auth:passkey:rename`. * `auth.deletePasskey` removes one. It needs recent authentication, ends every session of the person, and is audited as `auth:passkey:delete`. ```ts const passkeys = await client.auth.listPasskeys(); await client.auth.renamePasskey({ id: passkeys[0].id, name: 'YubiKey 5C' }); await client.auth.deletePasskey({ id: passkeys[1].id }); ``` Each listed passkey carries: | Field | Meaning | | ------------------------- | ---------------------------------------------------------------------------------------- | | `id`, `name` | The passkey's ID and label (at most 64 characters). | | `createdAt`, `lastUsedAt` | When it was registered, and last used to sign in or answer MFA. | | `deviceType` | `singleDevice` (bound to one authenticator) or `multiDevice` (synced). | | `backedUp` | Whether the credential is backed up, as reported at registration. | | `transports` | How the browser can reach the authenticator (`internal`, `hybrid`, `usb`, `nfc`, `ble`). | | `aaguid` | The authenticator model identifier, when the authenticator reports one. | Removing the last passkey is refused with `LAST_AUTHENTICATOR` unless the person has another way to sign in: a password, or a verified email or phone number with passwordless sign-in enabled. That keeps people from locking themselves out. `auth.mfaStatus()` reports how many passkeys a person has, and `tenants.usage` counts people with an authenticator or a passkey as MFA-enrolled. # Verification and recovery (/docs/guides/authentication/recovery) > Email verification, password reset and change, email and phone changes, lost second factors, and clearing lockouts. People forget passwords, change addresses, and lose phones. These flows let them recover without an administrator, and let administrators help when they must, without ever revealing whether an account exists. Each flow proves that the person controls an address by sending a single-use token or code there, through the delivery outbox. So each needs the matching callback (`sendEmail`, or `sendSms` for phone codes) and fails with `FEATURE_DISABLED` without it. Each flow has a start call that sends the message and a completion call that redeems it: | Flow | Starts with | Completes with | Token lifetime | Template | | ------------------ | ------------------------------- | ------------------------------- | -------------- | -------------------- | | Email verification | `auth.requestEmailVerification` | `auth.verifyEmail` | 24 hours | `verify-email` | | Password reset | `auth.requestPasswordReset` | `auth.resetPassword` | 10 minutes | `password-reset` | | Email change | `auth.requestEmailChange` | `auth.confirmEmailChange` | 10 minutes | `email-change` | | Phone verification | `auth.startPhoneVerification` | `auth.confirmPhoneVerification` | 5 minutes | `phone-verify` (SMS) | ## Email verification [#email-verification] An email address typed at sign-up could belong to anyone. Before you send password resets, security alerts, or one-time codes there, you want proof that the person actually receives mail at it. A person's email is verified when they accept an invitation, finish an emailed passwordless sign-in, confirm an email change, or follow a verification link. * `auth.requestEmailVerification` sends a verification link. It is public and always answers `{ success: true }`, whether or not the address exists, and it sends only to an active person whose address is not yet verified. * `auth.verifyEmail` redeems the token from the link, on the page it opens, and marks the address verified (audited as `auth:email:verify`). The link is valid for 24 hours. ```ts await client.auth.requestEmailVerification({ tenantId, email: 'bob@example.com' }); // Later, on the page the link opens: await client.auth.verifyEmail({ tenantId, token }); ``` With `authentication.requireEmailVerification` on (it defaults to the value of `signUpEnabled`), an unverified person cannot sign in and existing sessions stop working: both fail with `EMAIL_UNVERIFIED`. Some features also depend on a verified address: password reset emails, emailed MFA codes, and failed sign-in alerts are only sent to verified addresses. ## Password reset [#password-reset] A forgotten password should not need a support ticket. Password reset proves control of the verified email address instead, then lets the person choose a new password. ### Request a reset [#request-a-reset] `auth.requestPasswordReset` sends a reset link. Call it from your "forgot password" form. ```ts await client.auth.requestPasswordReset({ tenantId, email: 'alice@example.com' }); ``` The call always succeeds, so it cannot be used to discover accounts. A `password-reset` email is sent only when the address belongs to an active person in that tenant whose email is verified. ### Set the new password [#set-the-new-password] On the page the email links to (your `links.passwordReset` builder decides the URL), `auth.resetPassword` redeems the token and sets the new password: ```ts await client.auth.resetPassword({ tenantId, token, password: newPassword }); ``` The token is single-use and valid for ten minutes. Every password rule applies: the deployment screen and the tenant's length, character class, personal information, and history rules (`WEAK_PASSWORD`, `BREACHED_PASSWORD`, `PASSWORD_REUSED`). A reset ends every session of the person, forgets their remembered devices, and cancels other pending challenges. It never signs them in and never removes MFA: the person signs in with the new password and then their second factor. The reset is audited as `auth:password:reset`. A reset is also the way back from an expired password. When a tenant sets `passwordMaxAgeDays`, a correct but expired password is refused with `PASSWORD_EXPIRED`, only after it has been verified, so expiry never reveals whether a guess was right. ### Resetting on someone's behalf [#resetting-on-someones-behalf] Sometimes a person cannot start the reset themselves, for example because their address was never verified or they were created without a password. `identities.requestPasswordReset` lets an administrator queue the reset email for a member, whether their address is verified or not: ```ts const { queued, email } = await iam.api.identities.requestPasswordReset(adminCredential, { tenantId, identityId, }); ``` This needs `iam:identities:update` and recent authentication and is audited as `identity:password-reset`. Only an owner of the same tenant, or a root administrator, can trigger a reset for an owner; only a root administrator can for a root administrator. Refusals are audited as denials. ## Password change [#password-change] People change a password they still know when they suspect it leaked, or simply to rotate it. `auth.changePassword` takes the current password and the new one: ```ts await client.auth.changePassword({ currentPassword, password: newPassword }); ``` * It needs recent authentication (`RECENT_AUTH_REQUIRED`); see [recent authentication](/docs/guides/authentication/sessions#recent-authentication). * A wrong current password fails with `INVALID_CREDENTIALS` and is recorded as a failed attempt, like one at sign-in: someone holding a session may be guessing it. * On success, every session of the person ends, **including the one that made the change**, and remembered devices are forgotten. Send people back to sign in with the new password. Audited as `auth:password:change`. ## Email change [#email-change] People change jobs, domains, and providers, and their sign-in address has to follow. Because the address is also where resets go, a change must prove the new address before it takes effect. It happens in two steps: 1. `auth.requestEmailChange` sends a confirmation link to the new address. It needs a signed-in, recently authenticated session. 2. `auth.confirmEmailChange` redeems the token from that link and switches the account to the new address. ```ts // Signed in, with recent authentication: await client.auth.requestEmailChange({ email: 'alice@new.example' }); // On the page the confirmation email links to: await client.auth.confirmEmailChange({ tenantId, token }); ``` * The `email-change` message goes to the **new** address and is valid for ten minutes. * The token is bound to the session that asked for it. If that session has ended, confirmation fails with `UNAUTHENTICATED`. * An address already used by another identity in the tenant fails with `IDENTITY_EXISTS`. * On confirmation the new address is verified, every session ends, and remembered devices are forgotten. Audited as `auth:email:change`. Administrators change a member's address with `identities.update({ email })`. That needs recent authentication, leaves the new address **unverified**, revokes the person's sessions, and is audited as `identity:email-change`. The same owner and root protections as for password resets apply. ## Phone numbers [#phone-numbers] A verified phone number enables SMS sign-in codes (when `passwordlessSms` is on). Verification proves the number belongs to the person before codes are sent there. They add one from a signed-in, recently authenticated session: `auth.startPhoneVerification` texts a code to the number, and `auth.confirmPhoneVerification` checks the code and marks the number verified. ```ts await client.auth.startPhoneVerification({ phone: '+15551234567' }); // E.164 await client.auth.confirmPhoneVerification({ phone: '+15551234567', code: '482913' }); ``` The six-digit code is sent by SMS with the `phone-verify` template, is valid for five minutes, and is bound to the session that asked for it. A number already verified by another identity in the tenant fails with `PHONE_EXISTS`. Verification is audited as `auth:phone:verify`. ## A lost second factor [#a-lost-second-factor] When a person loses their authenticator, they sign in with their password and answer the challenge with one of their ten single-use **recovery codes** (`auth.recoverMfa`). Once signed in, they can enroll a new authenticator or regenerate their codes; see [recovery codes](/docs/guides/authentication/mfa#recovery-codes). A registered passkey can also answer the challenge. Resetting a password does not remove MFA, so a stolen mailbox alone is not enough to take over an account that has a second factor. ## Lockouts [#lockouts] Rate limits that stop an attacker also stop a person who mistyped their password too often. Sign-in, recovery, and MFA flows share persisted rate limits (`authentication.rateLimits`, tightened per tenant with `maxAttempts`). Recovery requests and code checks use the stricter sensitive tier. A refused attempt fails with `RATE_LIMITED` (429). The error carries `retryAfterMs`, the HTTP response adds a `Retry-After` header, and `IamClientError.retryAfterMs` exposes it to browser code. See [rate limits](/docs/guides/authentication/http#rate-limits). The counters reset by themselves when the window passes. When someone cannot wait, `identities.unlock` lets an administrator clear that person's counters right away: ```ts const { supported, cleared } = await iam.api.identities.unlock(adminCredential, { tenantId, identityId }); ``` `identities.unlock` needs `iam:identities:update` and recent authentication and is audited as `identity:unlock`. It clears the counters behind the person's sign-in, recovery, and MFA flows, keyed by their email, phone, and ID. It never clears a network's per-IP counter. A custom limiter opts in by implementing `reset`; without it the call reports `supported: false`. ## Recovering root access [#recovering-root-access] When every root administrator is locked out, a deployment operator creates a new root administrator with `iam.recoverRoot`, usually through the [`recover-root` CLI command](/docs/reference/cli#recover-root). It needs an email address the root tenant does not use yet (`IDENTITY_EXISTS` otherwise), and the new administrator enrolls MFA on first sign-in like the first one did. See [root administration](/docs/guides/concepts/tenants-and-identities#root-administration). # Sessions (/docs/guides/authentication/sessions) > Database sessions with absolute and idle lifetimes, device metadata, recent authentication, sign-out everywhere, session caps, and sign-in records. After someone signs in, every later request has to prove it comes from them. That proof is a session. In Better IAM a session is a database record, not a self-contained token such as a JWT. The bearer token (or cookie value) is a random secret returned once; only its hash is stored. Storing sessions costs a database read per request, and buys three things a self-contained token cannot offer: * **Instant revocation.** Signing out everywhere, disabling a person, or blocking a network takes effect on the next request, not when a token expires. * **Context.** Each session records how and where it was established, for device lists and security alerts. * **Live policy.** Each use is re-checked against the current state of the identity, the tenant, and its policy. ## Lifetimes [#lifetimes] Long sessions are convenient; short ones limit the damage of a stolen cookie or an unattended computer. Every user session balances the two with two clocks: * an **absolute lifetime**: the session ends this long after it was issued, however active it is; * an **idle timeout**: the session ends when nothing has used it for this long. | Option | Default | Range | | ------------------------------------- | ---------------------------------------- | -------------------------- | | `authentication.sessionLifetimeMs` | 7 days | one minute to 30 days | | `authentication.sessionIdleTimeoutMs` | the shorter of 24 hours and the lifetime | one minute to the lifetime | A tenant's `sessionLifetimeMs` and `sessionIdleTimeoutMs` shorten these for its members but never extend them. Validating a session updates its `lastSeenAt` at most once a minute, or once per tenth of the idle timeout when that is shorter, so busy clients do not turn every request into a write; idle expiry is accurate to that interval and only ever earlier than configured. ### Warning before an idle sign-out [#warning-before-an-idle-sign-out] Being signed out in the middle of filling a form is frustrating. Warn people first: `auth.getSession` returns the signed-in identity, the session, and the limits in force, and calling it also counts as activity. ```ts const { identity, session, limits } = await client.auth.getSession(); // limits: { lifetimeMs, idleTimeoutMs, idleExpiresAt, now } ``` `idleExpiresAt` is when the session lapses if nothing touches it again (the call itself just did), and `now` is the server's clock so you can correct for skew. A client can warn before an idle sign-out and keep the session alive on request by calling `getSession` again. The console does exactly that: it shows a countdown two minutes ahead with "Stay signed in", and returns to the login page once the session has lapsed. ## Revalidation on every use [#revalidation-on-every-use] A session that was valid an hour ago may not be valid now: the person may have been disabled, or the organization may have started requiring MFA. So a session is checked again each time it is presented: * the session exists and has not passed its absolute lifetime or idle timeout; * the identity is an active person in an active tenant with an active ancestry; * the email is verified when `requireEmailVerification` is on (`EMAIL_UNVERIFIED`); * the session passed MFA if the person now requires it (`MFA_REQUIRED`); * for an impersonation session, the administrator's own session and identity are still live; * the recorded client address is inside the tenant's `allowedIpRanges` (`IP_NOT_ALLOWED`) and not blocked (`IP_BLOCKED`), and, with `bindSessionsToIp`, the presenting address matches it (`SESSION_NETWORK_MISMATCH`). Provisioning operations and authorization queries repeat these checks on a fresh read inside their own transaction, and there an identity past its scheduled `expiresAt` is refused as well. A lapsed or revoked session fails with `UNAUTHENTICATED` (401). The browser client's `onUnauthenticated` hook fires for exactly `UNAUTHENTICATED` and `SESSION_NETWORK_MISMATCH`, so you can send people to the login page in one place. ## Device metadata [#device-metadata] People recognize their sessions by device ("Chrome on a MacBook, Berlin"), and security features need to know where a session came from. Each session records the sign-in `method` and the client it was established from: * `client.userAgent`, `client.ip`, and `client.label` (a short device name your application derives); * captured by the HTTP handler through `http.clientInfo(request)`, or by `iam.auth.withClient(info, fn)` for sessions you create with direct `iam.api.auth.*` calls. Without `http.clientInfo`, the handler records the `User-Agent` header only. Values are trimmed and bounded (IP 64, user agent 512, label 128 characters). Client details are informational: nothing about the client is trusted for authorization, and an IP is recorded only when your `clientInfo` derives it from a proxy header you control. See [client details](/docs/guides/authentication/http#client-details). `iam.auth.withClient(info, fn)` runs `fn` so that every session it issues records `info`. Use it when your own server code signs people in without going through the HTTP handler: ```ts title="Direct calls" const result = await iam.auth.withClient({ ip, userAgent, label: 'Kiosk 4' }, () => iam.api.auth.signIn({ tenantId, email, password }), ); ``` ## Listing and ending sessions [#listing-and-ending-sessions] A lost phone, a shared computer, or a suspicious sign-in all call for ending sessions. People manage their own from an account page: | Call | What it does | | ----------------------------------- | ----------------------------------------------------------------------------------------------------- | | `auth.listSessions()` | Lists the person's live sessions with their device details; `current` marks the one making the call. | | `auth.revokeSession({ sessionId })` | Ends one of their sessions, such as the one on a lost phone. Needs recent authentication. | | `auth.revokeOtherSessions()` | "Sign out everywhere else": ends every other session and keeps this one. Needs recent authentication. | | `auth.signOut()` | Ends the current session. Through the HTTP handler it also clears the session cookie. | ```ts const sessions = await client.auth.listSessions(); const phone = sessions.find((session) => !session.current && session.client?.label === 'Pixel 8'); if (phone) await client.auth.revokeSession({ sessionId: phone.id }); ``` Administrators handle other people's sessions, for support and incident response: | Call | Needs | Effect | | --------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `identities.listSessions` | `iam:identities:read` | One identity's live sessions, without token hashes (device lists, support). | | `identities.revokeSessions` | `iam:identities:update`, recent authentication | Ends every session of one identity without disabling it (incident response, a lost device); audited as `identity:revoke-sessions`. | | `tenants.revokeSessions` | `iam:tenants:update`, recent authentication | Ends every session in the tenant, including role sessions sourced from it and impersonation sessions opened through an ended session, and forgets the tenant's remembered devices. The caller's own session and devices are kept unless `includeSelf`. Audited as `tenant:revoke-sessions`. | Ending a session also ends the impersonation sessions an administrator opened through it. Revocation takes effect on the next request in every process, because nothing is cached. Some changes end every session of the person, including the one that made the change: a password change or reset, an email change, enrolling or disabling an authenticator (enrolling returns a fresh session), deleting a passkey, disabling the identity, and administrator revocation. ## Concurrent session limits [#concurrent-session-limits] Some organizations want to stop account sharing, or simply limit how many devices hold a live session. A tenant's `maxSessions` (1 to 100) caps the concurrent user sessions per person. When a new session would exceed the cap, the oldest live sessions end to make room. Impersonation sessions do not count toward the cap. ## Recent authentication [#recent-authentication] A session can live for days, and anyone who sits down at an unlocked computer inherits it. Recent authentication limits what they can do with it. Sensitive operations require the session to have been established within `authentication.recentAuthenticationMs` (five minutes by default, one second to fifteen minutes). They include password, email, factor, device, session, and ownership changes, impersonation, and policy changes. When the session is older, the operation fails with `RECENT_AUTH_REQUIRED`. ```ts import { IamClientError } from 'better-iam/client'; try { await client.auth.revokeOtherSessions(); } catch (error) { if (error instanceof IamClientError && error.code === 'RECENT_AUTH_REQUIRED') { const result = await client.auth.reauthenticate({ password: await askForPassword() }); if ('mfaRequired' in result) { // the account has a second factor: complete it, then retry } await client.auth.revokeOtherSessions(); } else throw error; } ``` `auth.reauthenticate({ password })` re-runs the password ceremony, and the second factor when the account has one, and issues a **fresh session** with a new `authenticatedAt`; through the HTTP handler it replaces the session cookie. The console prompts for it whenever an operation answers `RECENT_AUTH_REQUIRED`. Two kinds of credential never qualify, whatever their age: * impersonation sessions (`IMPERSONATION_RESTRICTED`), which also cannot re-authenticate; * temporary credentials such as assumed-role sessions, which carry their source's authentication time. ## Sign-in records and alerts [#sign-in-records-and-alerts] People rarely notice when someone else is guessing their password or has already signed in as them. Better IAM keeps the facts you need to tell them, the way a bank shows "last sign-in" on its home page. Every sign-in flow keeps a small sign-in record per person: when their last session was issued and from which client, and how many attempts since then named their account with a wrong password, authenticator or emailed code, or recovery code. Each failure is recorded as an `auth:signin:fail` audit event with `reason: 'password' | 'mfa' | 'recovery-code'` and the client's IP and user agent, in a transaction of its own because the refused flow rolled back. A new session carries the record as `session.previousSignIn` (absent on a first sign-in), and the record restarts. That lets you greet people with "last sign-in on Tuesday from Chrome, 3 failed attempts since", the way a login banner does: ```ts const { session } = await client.auth.getSession(); const previous = session.previousSignIn; // { lastAt?, lastClient?, failedAttempts, lastFailedAt?, lastFailedClient? } ``` Unknown addresses, disabled accounts, and attempts the rate limiter refused are never counted, so the record cannot be used to enumerate accounts or to flood the audit log. Two optional emails build on it: * **New sign-in notices.** With `authentication.signInNotifications`, or a tenant's `notifyNewSignIn` (which overrides the deployment), a session from a client (user agent and IP) that none of the person's live sessions or remembered devices has used queues a `new-sign-in` email with the session ID, method, user agent, IP, and label. Sessions without client details are never judged. * **Failed sign-in alerts.** With `authentication.failedSignInAlerts` set to a number (1 to 1,000; needs `sendEmail`), the person receives one `sign-in-failures` email the moment the streak reaches that number, sent only to a verified address, so they hear about a guessing attempt before their next sign-in. ### The security trail [#the-security-trail] An account page is more trustworthy when people can see what happened to their account. `auth.listSecurityEvents({ limit? })` gives people their own authentication trail without any administrative permission: the `auth:*` audit events recorded for their identity, newest first (50 by default, at most 200). It covers sign-ins, failed attempts, sign-outs, factor and password changes, and remembered devices. Each event carries its `metadata` (the client's `ip` and `userAgent`, the sign-in `method`, or a failure's `reason`) and names the administrator in `impersonatorId` when one acted through impersonation. | Event | Recorded when | | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | | `auth:identity:create` | The identity was created. | | `auth:session:create` | A session is issued (with `method`, `ip`, `userAgent`). | | `auth:session:revoke`, `auth:session:revoke-others` | A session is ended, or every other session. | | `auth:session:mismatch` | A bound session was presented from another network (both addresses in the metadata). | | `auth:signin:fail` | A wrong password, code, or recovery code for a real account. | | `auth:password:change`, `auth:password:reset` | The password changed. | | `auth:email:verify`, `auth:email:change`, `auth:phone:verify` | Contact details were verified or changed. | | `auth:mfa:enable`, `auth:mfa:disable`, `auth:mfa:recover`, `auth:mfa:recovery-codes` | The authenticator changed or a recovery code was used. | | `auth:passkey:create`, `auth:passkey:rename`, `auth:passkey:delete` | Passkeys changed. | | `auth:device:trust`, `auth:device:revoke` | A device was remembered or forgotten. | ## Session kinds and fields [#session-kinds-and-fields] Not every credential is a person's sign-in. Sessions come in four kinds, visible to policies as `principal.sessionKind`: `user` (sign-in and impersonation sessions), `api-key` (service-account keys), `role` (assumed roles), and `session-token` (temporary credentials). Code that branches on the kind must fail closed for kinds it does not know. The session a sign-in returns (`SafeSession`) never includes `tokenHash`. Its main fields: # Sign-in methods (/docs/guides/authentication/sign-in-methods) > Passwords and password screening, self-registration, magic links, email and SMS codes, federated sign-in, API keys, and assumed roles. Better IAM supports several ways to sign in, because no single method suits everyone: passwords are familiar, emailed links avoid passwords altogether, passkeys resist phishing, enterprises want their own identity provider, and machines need keys. You choose which methods your deployment offers, and each organization (a tenant) can narrow that set with its [tenant policy](/docs/guides/authentication/tenant-policy). Whatever the method, the result is the same: a session, or an MFA challenge when a second factor is required (see [how a sign-in works](/docs/guides/authentication#how-a-sign-in-works)). ## Choose the methods you offer [#choose-the-methods-you-offer] Methods are switched on under `authentication` in the server options. Features that send messages need a delivery callback: `sendEmail` hands a queued email to your mail provider, and `sendSms` does the same for text messages. Construction fails with `INVALID_CONFIG` when an enabled email feature has no `sendEmail`, or SMS codes have no `sendSms`, so a misconfiguration is caught at startup rather than when someone tries to sign in. ```ts title="iam.ts" import { betterIam } from 'better-iam'; import { pwnedPasswords } from 'better-iam/auth'; export const iam = betterIam({ // database, secret, baseURL ... authentication: { sendEmail: async (message) => mailer.send(message), // deduplicate retries by message.id sendSms: async (message) => sms.send(message), emailPassword: true, // the default; false turns password sign-in off passwordlessEmail: true, // magic links and emailed codes passwordlessSms: true, // SMS codes signUpEnabled: false, // the default passkeys: { rpID: 'example.com', rpName: 'Acme Cloud' }, passwordPolicy: { isBreached: pwnedPasswords() }, }, }); ``` ## Passwords [#passwords] Passwords remain the default for most products, so Better IAM makes them as safe as it can: strong hashing, screening against weak and breached passwords, and responses that never reveal whether an account exists. Call `auth.signIn({ tenantId, email, password, deviceToken? })` from your login form. It checks the password and returns a session or an MFA challenge. `deviceToken` comes from ["remember this device"](/docs/guides/authentication/mfa#remember-this-device); through the HTTP handler it is sent automatically from a cookie. ```ts const result = await client.auth.signIn({ tenantId, email: 'alice@example.com', password }); ``` * Passwords are hashed with **Argon2id** and must be 12 to 1,024 characters long. * A wrong password, an unknown address, and a disabled account all fail with the same `INVALID_CREDENTIALS`. Unknown and passwordless-only accounts perform the same password-hash work, so response times do not reveal which accounts exist. * A wrong password for a real, active account is recorded as a failed attempt (`auth:signin:fail`) and counted in the person's [sign-in record](/docs/guides/authentication/sessions#sign-in-records-and-alerts). * `emailPassword: false` disables password sign-in, reset, and self-registration (`FEATURE_DISABLED`). ### Password screening [#password-screening] Length alone does not make a password safe: `password1234` is twelve characters, and millions of longer passwords circulate in breach dumps that attackers try first. Screening refuses those at the moment a password is chosen. Every password rule applies wherever a password is set: identity creation, invitations, bulk onboarding, reset, and change. The deployment sets a baseline with `authentication.passwordPolicy`: `pwnedPasswords()` from `better-iam/auth` is a Have I Been Pwned client that uses k-anonymity: it sends only the first five characters of the password's SHA-1 hash and matches the returned suffixes locally. It times out after three seconds and fails open unless you ask it to fail closed: ```ts passwordPolicy: { isBreached: pwnedPasswords({ threshold: 1, // minimum breach count that rejects a password failClosed: true, // refuse with PASSWORD_CHECK_UNAVAILABLE when the service cannot be reached timeoutMs: 3_000, }), check: (password) => (/acme/i.test(password) ? 'Do not use the company name' : undefined), }, ``` Organizations tighten this further with their own rules: minimum length, character classes, no personal information, password history (`PASSWORD_REUSED`), and a maximum age (`PASSWORD_EXPIRED`). See [password rules](/docs/guides/authentication/tenant-policy#password-rules). ### Self-registration [#self-registration] Most business products add people by invitation, so self-registration is disabled by default. Turn it on with `authentication.signUpEnabled: true` when anyone should be able to join a tenant on their own, for example a community or a free tier. `auth.signUp` then creates the visitor's account in an existing tenant: ```ts const { identity, verificationRequired } = await client.auth.signUp({ tenantId, email: 'bob@example.com', name: 'Bob', password, }); ``` `signUp` never creates an identity in the root tenant (`FORBIDDEN`) and does not sign the person in. `requireEmailVerification` defaults to the value of `signUpEnabled`: while it is on, sign-up queues a `verify-email` message (valid for 24 hours) and unverified people cannot sign in (`EMAIL_UNVERIFIED`). See [email verification](/docs/guides/authentication/recovery#email-verification). Tenant plan limits apply (`LIMIT_EXCEEDED`). ## Magic links and one-time codes [#magic-links-and-one-time-codes] Many people reuse passwords or forget them. Passwordless sign-in avoids passwords altogether: it proves the person controls an email address or phone number by sending a single-use secret there. It is a two-step flow: 1. `auth.startPasswordless` sends a magic link or a six-digit code to the address the person typed. 2. `auth.finishPasswordless` checks the token from the link, or the code the person typed, and signs them in. **Magic link:** ```ts await client.auth.startPasswordless({ tenantId, destination: 'alice@example.com', channel: 'email', kind: 'magic-link', }); // On the page the link opens (your links.magicLink builder decides its URL): const result = await client.auth.finishPasswordless({ tenantId, destination: 'alice@example.com', token, // from the link }); ``` **Emailed code:** ```ts await client.auth.startPasswordless({ tenantId, destination: 'alice@example.com', channel: 'email', kind: 'code', }); const result = await client.auth.finishPasswordless({ tenantId, destination: 'alice@example.com', token: '482913', // the six-digit code the person typed }); ``` **SMS code:** ```ts await client.auth.startPasswordless({ tenantId, destination: '+15551234567', // E.164 channel: 'sms', kind: 'code', }); const result = await client.auth.finishPasswordless({ tenantId, destination: '+15551234567', token: '482913' }); ``` * `startPasswordless` always answers `{ success: true }`, whether or not the address belongs to anyone, so it cannot be used to enumerate accounts. It sends only when an active person in the tenant has that email, or has verified that phone number. * Links and codes are single-use and expire after **five minutes**. Codes are six digits. The outbox uses the `magic-link` template for links and `code` for codes. * SMS supports codes only (`INVALID_INPUT` for an SMS magic link), and only to a phone number the person has verified from their account first (see [phone numbers](/docs/guides/authentication/recovery#phone-numbers)). * Finishing an email flow marks the address verified. A flow fails with `INVALID_CHALLENGE` if the person's email or phone changed after the code was sent. * Passwordless sign-in never links accounts by matching an address; it signs in the identity that owns the address in that tenant. * Both steps use the sensitive rate-limit tier. The tenant's `allowedMethods` must include `passwordless-email` or `passwordless-sms`. * `finishPasswordless` accepts a `deviceToken` like `signIn`, and returns an MFA challenge when a second factor is required. ## Passkeys [#passkeys] Passkeys sign people in with a device-bound or synced WebAuthn credential instead of a password. They cannot be phished, and they satisfy MFA on their own. With discoverable passkeys, the browser's autofill picks the account and no email is needed. See [passkeys](/docs/guides/authentication/passkeys). ## Federated sign-in [#federated-sign-in] Enterprise customers want their people to sign in with the company's own identity provider, so that joining, leaving, and MFA are managed in one place. Federated sign-in delegates the proof to that provider through OpenID Connect (OAuth/OIDC, including Google and GitHub) or SAML. The protocol packages handle the ceremony. When it succeeds, they call `protocolHost.completeAuthentication`, which finishes like any other sign-in with the method `federated`: the tenant must allow the method, and root, tenant, and deployment MFA requirements apply. * Federation maps `(tenant, provider, issuer, subject)` to an identity. A new subject with a verified email provisions an account (`VERIFIED_EMAIL_REQUIRED` without one). * An email that already belongs to an identity fails with `ACCOUNT_LINK_REQUIRED`. The person signs in to the existing account and links the provider explicitly; an email collision is never treated as proof of ownership. * `mapAttributes` stores provider attributes on the identity, validated against `permissions.identityAttributes`, and replaces them on every sign-in. See [OAuth sign-in](/docs/federation/oauth-sign-in) and [SAML](/docs/federation/saml). ## Service accounts and API keys [#service-accounts-and-api-keys] Scripts, integrations, and other servers cannot type a password or answer an MFA prompt. They sign in with API keys issued to service accounts. A key is an opaque bearer token sent as `Authorization: Bearer `; only its hash is stored, so a database leak does not reveal usable keys. `credentials.create` issues a key for a service account and returns the token once. Store it in your secret manager right away; it cannot be shown again. ```ts const { token, credentialId, expiresAt } = await iam.api.credentials.create(adminCredential, { tenantId, identityId: serviceAccountId, name: 'nightly-export', scopes: ['reports:export'], // or a session `policy`; not both expiresInSeconds: 30 * 86_400, // 30 days; 90 days when omitted }); ``` * Keys expire (90 days by default). `credentials.rotate` replaces a key with a new one and invalidates the old key in the same transaction, for scheduled rotation or after a leak. `credentials.revoke` ends a key at once, and `credentials.update` changes its label, description, or expiry. * `lastUsedAt` is updated at most once a minute. `credentials.list({ unusedForMs })` finds keys nobody has used for that long, so you can revoke them. * A key keeps the ceiling of the grant authority that issued it, and stops working when that authority is revoked or the service account is disabled or past its `expiresAt`. * API keys are judged against the address presenting them: a key used from a blocked network is refused. See the [credentials reference](/docs/reference/api/credentials). ## Assumed roles [#assumed-roles] Sometimes an identity in one tenant must act inside another: your support team working in a customer's organization, or a central automation managing many tenants. Rather than creating accounts everywhere, role assumption gives a short-lived session with one specific role in the target tenant. It rests on a **trust**: an exact source-identity to target-role relationship that a root administrator creates with `trust.create`. The source identity then calls `roles.assume` to receive a role session: ```ts const { token, session } = await iam.api.roles.assume(credential, { tenantId: targetTenantId, trustId, durationSeconds: 900, // 15 minutes, the default; at least 60 }); ``` `roles.assume` returns the role session's bearer `token`, which the caller sends as `Authorization: Bearer` to act in the target tenant. The HTTP handler never sets it as a cookie, so the caller's own session stays intact. * Assumption also requires the source identity's `iam:roles:assume` permission on `iam/{roleId}`. A trust may require MFA (the default) and an external ID. * Role sessions last 15 minutes by default and at most an hour, unless the trust's `maxSessionSeconds` and the deployment's `sts.maxRoleSessionSeconds` allow longer. They never outlive the source credential. * Role sessions cannot chain (`ROLE_CHAINING_DISABLED`), cannot be started from an impersonation session, and are never recently authenticated, so they cannot perform sensitive operations. * Revoking the trust, the source session, the source identity, or a relevant grant authority ends the role session at its next use. ## Restricting methods per tenant [#restricting-methods-per-tenant] Not every organization wants every method you offer. A company that runs its own identity provider may insist on federated sign-in only. An organization's `allowedMethods` lists the sign-in methods it accepts: `password`, `passwordless-email`, `passwordless-sms`, `passkey`, and `federated`. Unset means every method the deployment enables. A method outside the list is refused with `METHOD_NOT_ALLOWED` before any credential is examined. See [tenant policy](/docs/guides/authentication/tenant-policy#restricting-sign-in-methods). ## Failed attempts and lockouts [#failed-attempts-and-lockouts] Attackers guess passwords and codes at scale, so sign-in, recovery, and MFA flows share persisted rate limits per account, and optionally per client IP. A refused attempt fails with `RATE_LIMITED` and a `retryAfterMs` that tells the client when to try again. When a real person locks themselves out, an administrator clears their counters with `identities.unlock`; when one address keeps attacking, an incident responder refuses it with `security.blockNetwork`. See [rate limits](/docs/guides/authentication/http#rate-limits) and [network restrictions](/docs/guides/authentication/tenant-policy#network-restrictions). # Tenant authentication policy (/docs/guides/authentication/tenant-policy) > Per-organization sign-in rules - required MFA, allowed methods, password rules, session limits, remembered devices, IP allowlists, session binding, and network blocks. Different customers need different sign-in rules. A bank may insist on MFA, passkeys only, and office networks; a small team may want nothing beyond the defaults. The **tenant authentication policy** lets each organization (a tenant) set its own rules without you shipping per-customer configuration. The policy can only **tighten** what the deployment allows. You decide in `authentication` which methods exist and how long sessions may last at most; a tenant can require more and allow less, never the reverse. ## Set a policy [#set-a-policy] `tenants.setAuthPolicy` replaces a tenant's whole policy. Call it from an organization's security settings page. Organization owners can call it for their own tenant; it needs `iam:tenants:update` and recent authentication. Pass `null` to clear the policy. The current policy is on the tenant record (`tenants.get` returns it as `authPolicy`), so read it first when you change a single field. ```ts await iam.api.tenants.setAuthPolicy(ownerCredential, { tenantId, authPolicy: { requireMfa: true, allowedMethods: ['passkey', 'password'], sessionIdleTimeoutMs: 30 * 60_000, maxSessions: 5, minPasswordLength: 14, passwordHistory: 5, trustedDeviceDays: 7, notifyNewSignIn: true, }, }); ``` Every field is optional, unknown fields are refused, and each value is range-checked (`INVALID_INPUT`). A deleted tenant cannot be changed (`INVALID_TRANSITION`). Each change is audited as `tenant:auth-policy` with the new policy. To apply a policy to every organization from the start, for example as part of a SaaS plan, set `tenantDefaults.authPolicy` in the server options. It is validated at construction and stamped on every tenant that `tenants.create` creates: ```ts title="iam.ts" tenantDefaults: { authPolicy: { requireMfaForOwners: true, mfaEmailCodes: true }, }, ``` ## Policy fields [#policy-fields] ## How the policy is enforced [#how-the-policy-is-enforced] The authentication service consults the policy at three points: 1. **When a sign-in starts:** the method must be allowed, and the tenant's `maxAttempts` caps the rate limit. 2. **When a session is issued:** MFA requirements, the IP allowlist, the session lifetime, `maxSessions`, and new sign-in notices apply. 3. **Every time a session is used:** the idle timeout, the MFA requirement, the IP allowlist, and IP binding are checked again. Because of the third point, existing sessions follow a new policy on their next use. Requiring MFA locks out sessions that did not complete it right away, and a person cannot disable their authenticator while the tenant requires MFA. The policy only ever tightens: * Session lifetimes and attempt limits take the smaller of the tenant's and the deployment's values. * `requireMfa` **adds to** the deployment's `authentication.requireMfa(tenant, identity)` callback; it never replaces it. * Root administrators always need MFA, whatever the policy says. ## Requiring MFA [#requiring-mfa] The problem MFA solves is a stolen or guessed password. Two switches cover the common rollouts: * `requireMfaForOwners: true` protects the owners first, the people who can change the policy and grant access. * `requireMfa: true` then covers everyone. People without a factor are asked to enroll an authenticator on their next sign-in, and sessions that did not pass MFA stop working immediately. To avoid forcing every member to install an authenticator app, add `mfaEmailCodes: true`: people with nothing enrolled can then answer the challenge with a code sent to their verified email address. See [multi-factor authentication](/docs/guides/authentication/mfa). ## Restricting sign-in methods [#restricting-sign-in-methods] `allowedMethods` lets an organization accept only the methods it trusts, for example `['federated']` to force everyone through the company's identity provider, or `['passkey']` for phishing-resistant sign-in only. The check runs before any credential is examined, so a rejected method never reveals whether a password was right. Impersonation is an administrative action, not a sign-in method, so it is not listed; the policy's `allowImpersonation` controls it. `domains.discover` reports a tenant's `allowedMethods` and MFA requirement, so a login page can show the right buttons as soon as someone types their work email. ## Password rules [#password-rules] Deployment-wide screening (common passwords, a breach corpus, a custom check) applies to everyone; see [password screening](/docs/guides/authentication/sign-in-methods#password-screening). A tenant adds its own rules on top, and every rule applies wherever a password is set: creation, invitations, bulk onboarding, reset, and change. * **Length and variety.** `minPasswordLength` (12 to 128) and `passwordMinClasses` (2 to 4 of lowercase, uppercase, digits, and symbols). Failures return `WEAK_PASSWORD`. * **No personal information.** `passwordRejectPersonalInfo` refuses a password that contains the email local part or a name word of four or more characters (`WEAK_PASSWORD`). * **History.** `passwordHistory` refuses the last 1 to 24 passwords, counting the current one (`PASSWORD_REUSED`). A new password is compared with Argon2 against the current hash and as many earlier ones as the setting asks for. Up to 24 earlier hashes are retained, and they are deleted with the identity. * **Maximum age.** `passwordMaxAgeDays` expires a password that many days after it was set (or after the identity was created, for older records). An expired password is refused with `PASSWORD_EXPIRED` only after it has been verified, so expiry never reveals whether a guess was right. The person recovers through [password reset](/docs/guides/authentication/recovery#password-reset). ## Sessions and devices [#sessions-and-devices] Shorter sessions limit how long a stolen cookie or an unattended laptop stays useful. * `sessionLifetimeMs` and `sessionIdleTimeoutMs` shorten the deployment's absolute lifetime and idle timeout. * `maxSessions` caps how many sessions one person may hold at once; the oldest ends when a new one is issued. * `trustedDeviceDays` shortens, or with `0` disables, "remember this device". * `notifyNewSignIn` emails people about sessions from clients they have not used before. See [sessions](/docs/guides/authentication/sessions). ## Network restrictions [#network-restrictions] Some organizations must keep access inside their own networks, and every organization needs a way to shut out an attacker's address during an incident. Three tools cover this. All of them judge the client IP that Better IAM recorded, so they need `http.clientInfo` configured behind a proxy you control; a sign-in or request without a recorded IP (direct API use, or the handler without `clientInfo`) is not judged. See [client details](/docs/guides/authentication/http#client-details). ### IP allowlist [#ip-allowlist] `allowedIpRanges` lists the networks (IPv4 or IPv6 addresses, or CIDR blocks) the tenant's people may sign in from. ```ts authPolicy: { allowedIpRanges: ['203.0.113.0/24', '2001:db8::/32'] }, ``` * Issuing a session, including an impersonation session, from outside every range fails with `IP_NOT_ALLOWED`. * A session whose recorded IP falls outside the ranges stops working at its next use, so tightening the list cuts off existing sessions. * An assumed-role session is judged against the allowlist of the organization it acts in. * For your own organization, the list must include the address your session was issued from and the address the request comes from; otherwise the change is refused (`INVALID_INPUT`) so you cannot lock yourself out. * Refusals show up as `denied` spans with code `IP_NOT_ALLOWED`. ### Binding sessions to their network [#binding-sessions-to-their-network] `bindSessionsToIp: true` makes a stolen session cookie useless from anywhere else. A user session is then accepted only from the address it was issued from. * A use from another address is refused with `SESSION_NETWORK_MISMATCH` (401) and recorded in the person's trail as `auth:session:mismatch`, with both addresses in the metadata. * The person simply signs in again from the new network, while the old session keeps working from the old one. * Sessions and requests without a recorded address are not judged. * The browser client's `onUnauthenticated` hook fires for this code, so people land on the login page. ### Network blocks [#network-blocks] Network blocks are the incident-response counterpart of the allowlist: when an address keeps guessing passwords, block it. Unlike the other fields on this page, blocks are managed with the `security` group rather than the policy. ```ts const block = await iam.api.security.blockNetwork(adminCredential, { tenantId, network: '198.51.100.23', // an address or a CIDR block reason: 'Password spraying against several members', durationMs: 24 * 60 * 60_000, // optional: one minute to a year; omit to block until lifted }); ``` * `security.blockNetwork` refuses every authentication flow and every live session whose recorded IP falls in the network, and every API key or assumed-role token presented from it, with `IP_BLOCKED`. It needs `iam:security:manage` and recent authentication, and is audited as `security:network-block`. * The check runs **before** rate limits and credentials, so a blocked address cannot count against anyone's attempts. * Blocking a network that contains your own address is refused, so nobody locks themselves out. Blocking the same network again renews the block. * `security.unblockNetwork({ tenantId, blockId })` lifts a block early (audited as `security:network-unblock`). * `security.listBlocks({ tenantId })` shows the tenant's blocks, newest first, each with `active` telling whether it has lapsed. It needs `iam:security:read`. * An organization's blocks apply to itself. Root administrators can set `platform: true` on the root tenant to block a network for every tenant. * Each process reuses a tenant's block list for five seconds, so a change reaches other processes within that time. Lapsed blocks are deleted by the retention worker. The console's administration panel can block a source address platform-wide for a day in one click from its sign-in failures page, and organization owners manage their own blocks on the settings page. ## Impersonation [#impersonation] `allowImpersonation` is off by default. Turning it on lets administrators who hold `iam:identities:impersonate` open restricted, fully audited "view as" sessions for members. See [impersonation](/docs/guides/authentication/impersonation). # Audit chain (/docs/guides/events/audit-chain) > The tamper-evident, hash-chained audit log per tenant, and how to verify it, export it, archive it continuously, and prune it with checkpoints. An audit log is only useful as evidence if nobody can quietly change it. Someone with database access could delete the record of what they did, or edit a denial into an approval. Better IAM makes that detectable: every tenant's audit log is an audit chain, a hash chain where each event carries the hash of the one before it. Changing, reordering, or removing a stored event breaks the chain, and verification says exactly where. This page explains how the chain works, how to verify it, how to keep an independent copy, and how to delete old events without breaking verification. ## How the chain works [#how-the-chain-works] Each event carries three chain fields: * `sequence`: its position in the tenant's chain, from 1. * `previousHash`: the hash of the previous event, or sixty-four zeros for the first. * `hash`: SHA-256 over the canonical JSON of the event without `hash`. Canonical JSON sorts object keys recursively and omits `undefined` values; `canonicalJson` from `better-iam/core` produces it. The chain head per tenant (the last sequence and hash) is stored in `auditChains` and advanced inside the transaction that records the event. Every writer goes through the same append: provisioning operations, denials, authentication events, SCIM provisioning, the OAuth provider, and deployment operations. Events recorded by versions without the chain are chained once, in timestamp order per tenant, the next time `initialize()` runs. On very large logs, run that first `initialize()` after upgrading during a maintenance window, because it backfills in one transaction. > **What the chain proves.** A chain proves that stored events were not altered, reordered, or removed after the fact by anyone without write access to both the events and the chain head. It does not stop someone with full database access from rewriting both. Export regularly to independent storage and compare heads. ## Verify [#verify] Verification recomputes the chain from storage and reports the first place it breaks. Run it on a schedule, and before you rely on the log as evidence. **API:** ```ts const status = await iam.api.audit.verify(credential, { tenantId }); if (!status.valid) alert(`Audit chain broken at ${status.failure?.sequence}: ${status.failure?.reason}`); // { valid, checked, unchained, first, last, lastHash, head, failure? } ``` **CLI:** ```sh better-iam audit-verify --config better-iam.config.mjs --tenant TENANT_ID ``` `audit.verify({ tenantId, fromSequence?, toSequence? })` (`iam:audit:read`) walks the stored events and checks contiguous sequences, linked hashes, recomputable hashes, and, for a full verification, a chain head equal to the last event. With `fromSequence` or `toSequence` it verifies that window against its own links. The result counts the events it `checked` and the `unchained` ones (recorded before the chain existed and not yet backfilled, never counted as failures), names the `first` and `last` sequence and the `lastHash`, and returns the stored `head`. When verification fails, `failure` names the sequence, the event ID, and the reason: | Reason | Meaning | | ------------------------ | --------------------------------------------------------------------------------------- | | `sequence-gap` | A sequence number is missing: an event was deleted from the middle of the chain. | | `previous-hash-mismatch` | An event does not link to the one before it: events were removed or reordered. | | `hash-mismatch` | An event's content no longer matches its hash: it was edited. | | `head-mismatch` | The last stored event is not the recorded chain head: events were removed from the end. | The CLI `audit-verify` does the same straight from storage as a deployment operation: it needs no credential, records no audit event, and exits non-zero (`AUDIT_CHAIN_BROKEN`) when the chain does not verify. ## Export [#export] Export copies the chain out of the database, so a copy exists that a database administrator cannot rewrite. ```ts title="Page through the chain" let from = 1; for (;;) { const page = await iam.api.audit.export(credential, { tenantId, fromSequence: from, limit: 5000 }); await archive.append(page.body); // JSON Lines in sequence order, without a trailing newline if (!page.nextSequence) break; from = page.nextSequence; } ``` `audit.export({ tenantId, fromSequence?, limit? })` (`iam:audit:read`) returns chained events in sequence order as JSON Lines (`body`), with `count`, `firstSequence`, `lastSequence`, `nextSequence` for the following page, and the current `head`. `limit` is 1 to 10 000 (1000 by default). Archive pages as they are. The CLI `audit-export --tenant ID --output audit.jsonl` writes the whole chain to a new file (it refuses to overwrite one), also without a credential or an audit event. ### Verify an archive anywhere [#verify-an-archive-anywhere] `verifyAuditChain(events, { previousHash?, head? })`, exported by `better-iam` and `@better-iam/core`, verifies events outside the server. It uses Web Crypto, so it runs in browsers and workers too. Pass the last hash of the previous page as `previousHash` so pages link, and a known `head` to check the end. ```ts title="Verify an exported page" import { verifyAuditChain } from 'better-iam'; const events = page.body.split('\n').map((line) => JSON.parse(line)); const result = await verifyAuditChain(events, { previousHash: lastHashOfPreviousPage }); if (!result.valid) throw new Error(`Archive broken at ${result.failure?.sequence}`); lastHashOfPreviousPage = result.lastHash; ``` Events are sorted by sequence first, so exports and database reads can be passed as they come. A run that starts mid-chain without `previousHash` links from its first event's own `previousHash`. ## Continuous archiving [#continuous-archiving] Exporting by hand is easy to forget. Configure `auditArchive` and schedule `iam.archiveAudit()` (CLI `audit-archive`) every few minutes to keep an independent copy of every tenant's chain automatically. ```ts title="iam.ts" import { createJsonlAuditArchive } from 'better-iam/server'; const iam = betterIam({ // ... auditArchive: createJsonlAuditArchive({ directory: '/var/lib/better-iam/audit' }), // or your own sink, write-once per range: // auditArchive: { write: (batch) => putObjectIfAbsent(`${batch.tenantId}/${batch.fromSequence}-${batch.toSequence}`, batch) }, }); ``` Each run reads every tenant's events after its archive cursor (collection `auditArchiveCursors`), in chain order and in batches (`batchSize`, 1000 by default, up to 10 000). Each batch is checked with `verifyAuditChain` against the previous batch's `lastHash` before it is handed to `write`. The cursor moves only after `write` resolves. After a crash, a batch can therefore be written again, possibly covering a longer range. A sink must: * key stored batches by `tenantId`, `fromSequence`, and `toSequence`; * never replace a stored batch with different content, and throw instead. One run at a time holds a tenant: a lease on its cursor that the run renews before each batch. A second run skips the tenant and lists it under `busy`, so overlapping schedules or instances never race each other's batches. `archiveAudit({ tenantId?, limit? })` archives at most `limit` events per run (100 000 by default) and returns: * `archived` per tenant and the number of `batches`; * `failed`: a chain that does not verify (for example an edited row) with `AUDIT_CHAIN_BROKEN`, a failing sink with `ARCHIVE_WRITE_FAILED`, or a batch that conflicts with a stored one with `ARCHIVE_CONFLICT`. Nothing past a failure is archived, and the CLI exits non-zero (`AUDIT_ARCHIVE_FAILED`); * `gaps`: sequences deleted before they were archived; * `busy` tenants and `truncated` when the run stopped at its limit. Without `auditArchive`, `archiveAudit` fails with `NO_AUDIT_ARCHIVE`. ### The file archive [#the-file-archive] `createJsonlAuditArchive` writes one file per batch, `{tenantId}/{fromSequence}-{toSequence}.jsonl` with zero-padded sequences, so files sort in chain order. Files are write-once: each is written under a unique temporary name, flushed, and published with a hard link that never replaces an existing file, and the directory is flushed too (not possible on Windows). Writing the same batch again is accepted; different events under an existing name are refused with `ARCHIVE_CONFLICT`, so a restored or tampered database cannot overwrite archived events. After a crash, files can overlap; read them by `sequence`. `better-iam audit-verify-archive --directory DIR --tenant ID` checks one tenant's archive on its own, without the database. It verifies that overlapping copies agree, that no sequence is missing, and that every hash and link recomputes, and exits non-zero (`AUDIT_ARCHIVE_INVALID`) otherwise. ## Prune with checkpoints [#prune-with-checkpoints] Audit logs grow forever unless you delete old events, but deleting from the start of a chain would normally break verification. Pruning leaves a checkpoint instead. **API:** ```ts const result = await iam.pruneAudit({ tenantId, retentionMs: 365 * 86400_000 }); // { deleted, prunedThroughSequence, prunedThroughHash, heldForArchive? } ``` **CLI:** ```sh better-iam audit-prune --config better-iam.config.mjs --tenant TENANT_ID --retention-days 365 ``` `iam.pruneAudit({ tenantId, retentionMs })` (CLI `audit-prune`, `--retention-days` 365 by default) deletes the longest prefix of a tenant's chain older than the cutoff and appends an `audit:prune` checkpoint. Its metadata records the deleted count and the sequence and hash the chain now starts after (`prunedThroughSequence`, `prunedThroughHash`). Verification keeps working from the checkpoint because a run may start mid-chain, and the archive you exported before pruning still links to it through `previousHash`. It is a deployment operation with no credential. > **Pruning never outruns the archive.** Once a tenant has an archive cursor, or wherever `auditArchive` is set, `pruneAudit` deletes only events the archive already holds. This holds in every process, including ones without the option. It reports `heldForArchive: true` when it stopped early, so the database never drops an event the archive lacks. A retention routine for one tenant: ### Archive [#archive] Run `audit-archive` every few minutes, or export pages with `audit.export` to independent storage. ### Verify [#verify-1] Verify the stored chain with `audit-verify` and the archive with `audit-verify-archive` or `verifyAuditChain`, and compare heads. ### Prune [#prune] Run `audit-prune` with your retention period. `doctor` reports `audit-archive-behind` when a tenant has unarchived events older than a day. - [audit API reference](/docs/reference/api/audit): list, verify, and export. - [Background jobs](/docs/operations/jobs): Scheduling archive and prune runs. # Events and audit (/docs/guides/events) > Every audit record is an event, recorded in the transaction that made the change and fanned out to subscribers, webhooks, and the audit chain. Your application and your security team both need to know when access changes. The application may sync a new member to a billing system or invalidate a cache when a role changes; the security team wants every administrator elevation in its SIEM and an alert on repeated failed sign-ins. And auditors want a record of all of it that nobody can quietly edit. Better IAM serves all three from one source: the audit log. Every audit record is also an event you can react to. Each of these produces one audit record inside the transaction that made the change: provisioning operations, authentication events (`auth:session:create`, `auth:mfa:enable`, and so on), denials, root overrides, invitation redemptions, access-request decisions, and deployment operations. The record is appended to the tenant's tamper-evident audit chain and fanned out in that same transaction. So nothing is emitted for a change that rolled back, and nothing committed is lost. You can consume events three ways: * **In-process subscribers** run your code in the application for matching events. Use them to update caches, metrics, or other systems in the same codebase. * **Webhooks** send signed HTTPS requests to an endpoint. Use them for SIEMs, alerting, and services outside your application. * **The audit log** answers questions after the fact, such as "who changed this role last week?". There is one deliberate exception to "nothing is emitted for a change that rolled back". `auth:signin:fail` records a wrong password, factor, or recovery code presented for a real account. The refused sign-in rolled back, so the event is appended in a transaction of its own, with `metadata.reason`, `metadata.ip`, and `metadata.userAgent`. That makes it a good subscription for brute-force alerting. ## The event [#the-event] Every event has the same shape, whichever way you receive it: Audit records omit passwords, keys, and token bodies, so nothing that consumes events ever receives them. The [audit chain](/docs/guides/events/audit-chain) page explains `sequence`, `previousHash`, and `hash`, and the [lifecycle events](/docs/guides/events/lifecycle-events) page lists the metadata of the access lifecycle events. ## In-process subscribers [#in-process-subscribers] When the reaction lives in your own codebase, subscribe to events directly instead of running a webhook endpoint. `iam.events.subscribe(patterns, handler)` registers a handler for events whose action matches any of the patterns and returns a function that unsubscribes it: ```ts title="worker.ts" const stop = iam.events.subscribe(['iam:identities:*', 'access-request:*'], async (event) => { await metrics.count(event.action, { tenant: event.tenantId, outcome: event.outcome }); }); // Later stop(); ``` * Patterns use the policy glob syntax (`*` and `?`) and may be a single string or a list. * The `events.onEvent` option receives every event, for a single catch-all handler configured with the instance. * Plugin `afterAudit` hooks share the same queue, so plugins react to events the same way. ### Dispatch [#dispatch] Handlers do not run inside the request that caused the event. If they did, a slow or failing handler could delay or break the operation that already committed. Instead the event is queued, and a dispatcher delivers it. Call `iam.events.dispatch()` (the same function as `iam.dispatchAuditHooks()`) from your worker schedule, next to `iam.auth.dispatchOutbox()`, which delivers emails and webhooks. It runs the queued events through every handler and returns `{ dispatched }`. ```ts title="worker.ts" setInterval(async () => { await iam.events.dispatch(); await iam.auth.dispatchOutbox(); }, 60_000); ``` * Dispatch is at least once. A handler that throws leaves its row queued for the next run, so handlers must be idempotent by `event.id`. * Subscribers live in memory, so dispatch in the process that registers them. The CLI `outbox` command serves only plugins and `events.onEvent`. * `doctor` reports audit hooks that have waited more than 15 minutes, a sign that nothing is dispatching. Framework integrations wrap this. In NestJS, `@OnIamEvent('identity:*')` subscribes a provider method, and `dispatchIntervalMs` runs dispatch inside the app on a timer; for multi-instance deployments, dispatch from a single worker. See [NestJS](/docs/frameworks/nestjs) and [background jobs](/docs/operations/jobs). ## Query the audit log [#query-the-audit-log] For questions after the fact, such as a member's activation history or last week's denials, read the log. `audit.list` (`iam:audit:read`) returns a tenant's events newest first, filtered as you need: ```ts const denials = await iam.api.audit.list(credential, { tenantId, action: 'iam:bindings:*', // exact name or glob outcome: 'deny', from: Date.now() - 7 * 86400_000, limit: 100, }); ``` `limit` is 1 to 1000 (100 by default) with an `offset`. `action` accepts an exact name or a glob pattern; `actorId`, `resourceId`, and `outcome` match exactly; `from` and `to` bound the timestamp. ## Where to go next [#where-to-go-next] - [Webhooks](/docs/guides/events/webhooks): Signed HTTPS deliveries with filters, retries, and redelivery. - [Lifecycle events](/docs/guides/events/lifecycle-events): Every binding, identity, package, invariant, and configuration event with its metadata. - [Audit chain](/docs/guides/events/audit-chain): The tamper-evident hash chain: verify, export, archive, and prune. # Lifecycle events (/docs/guides/events/lifecycle-events) > Every access lifecycle event, from role activations and offboarding to package rules and invariants, with what it means and the metadata it carries. Operations are audited under their permission names, such as `iam:bindings:create` or `iam:identities:update`. That tells you *which call* ran. The access lifecycle features also record events of their own that tell you *what happened to someone's access*: a role was elevated, a request was approved, an account expired, a package rule held back a risky change. These are the events to route to alerting, ticketing, and compliance reporting. All of them are ordinary audit events. They land in the tenant's audit chain, reach in-process [subscribers](/docs/guides/events#in-process-subscribers), and can be sent to a webhook ([webhooks](/docs/guides/events/webhooks)) by pattern, such as `binding:*`, `identity:*`, or `package:auto-*`. ```ts title="Route lifecycle events to an operations channel" await iam.api.webhooks.create(credential, { tenantId, url: 'https://ops.example.com/hooks/iam', events: ['binding:*', 'identity:*', 'package:*', 'invariant:*'], description: 'Access lifecycle alerts', }); ``` Events recorded by scheduler jobs carry the actor `deployment-operator` instead of a person. Events with outcome `deny` report a problem rather than a refused request. ## Elevation [#elevation] An eligible binding gives someone the right to take a role for a limited time; an activation is one such period. These events follow [just-in-time elevation](/docs/guides/privileged-access/elevation). Subscribe to `binding:*` to know whenever someone holds privileged access and why. | Event | What happened, and why you would care | Metadata | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | `binding:activate` | A member activated an eligible binding and now holds the role. Alert on it to see every elevation as it happens, with the stated reason. | `activationId`, `roleId`, `expiresAt`, `justification` | | `binding:activation-requested` | Activation needs approval and a request was recorded. Use it to page approvers or open a ticket; `expiresAt` is when the request lapses. | `activationId`, `roleId`, `expiresAt` (lapse), `requestedDurationMs`, `justification` | | `binding:activation-approved` | An approver granted a request and the role is live until `expiresAt`. Keep it as evidence of two-person control. | `activationId`, `roleId`, `identityId`, `expiresAt`, `note` | | `binding:activation-denied` | An approver refused a request. Tell the requester, or watch for repeated refusals. | `activationId`, `roleId`, `identityId`, `note` | | `binding:deactivate` | An activation ended early: the holder stepped down, withdrew a request (`cancelled`), or an administrator ended it (`revoked`). A `revoked` activation often means incident response. | `activationId`, `roleId`, `identityId`, `cancelled`, `revoked` | ## Identities [#identities] These events follow accounts through their [lifecycle](/docs/guides/privileged-access/lifecycle): expiry, offboarding, and data exports. Subscribe to `identity:*` to deprovision people in other systems at the same moment. | Event | What happened, and why you would care | Metadata | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | `identity:expire` | The purge worker disabled a person or service account past its `expiresAt` (actor `deployment-operator`). Use it to close the contractor's accounts in other systems too. | `kind`, `expiresAt` | | `identity:offboard` | `identities.offboard` disabled someone and removed their access in one transaction. Trigger downstream deprovisioning and keep the counts as offboarding evidence. | `reason`, `kind`, `successorId`, and the counts of everything removed | | `identity:export` | A data-subject export of someone's stored data was produced. Privacy teams track these requests. | `kind`, `auditIncluded` | | `identity:expiry-reminder` | `sendExpiryReminders` emailed a person that some of their access ends soon (actor `deployment-operator`). The item keys make sure nothing is reminded twice. | `count`, `earliest`, `items` (item keys) | ## Organization [#organization] These events record organization-wide access settings and the digest sent to owners. | Event | What happened, and why you would care | Metadata | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | | `tenant:access-policy` | `tenants.setAccessPolicy` changed the organization's floors for activation (length, justification, MFA, approval). Loosened floors deserve a second look. | `accessPolicy` | | `tenant:access-digest` | `sendAccessDigest` emailed the owners their access digest (actor `deployment-operator`). The digest job reads it back to send at most one digest per 20 hours. | `recipients` and the finding counts | ## Access packages [#access-packages] An access package bundles roles and group memberships that are granted together. These events follow [access packages](/docs/guides/privileged-access/access-packages) assigned by hand or requested by members. | Event | What happened, and why you would care | Metadata | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `package:assign` | `packages.assign` granted a package. `skipped` lists groups the person already had for longer; `replacedAutomatic` means a rule-based assignment became manual. | `packageId`, `packageName`, `identityId`, `bindings`, `memberships`, `skipped`, `expiresAt`, `justification`, `replacedAutomatic` | | `package:revoke` | `packages.revoke` removed an assignment and exactly the records it created. | `packageId`, `packageName`, `identityId`, `bindings`, `memberships` | | `package:request` | A member asked for a requestable package. Notify approvers or open a ticket; `lapsesAt` is when the request expires. | `requestId`, `packageId`, `packageName`, `lapsesAt`, `desiredExpiresAt`, `justification` | | `package:request-approved` | An approver granted a request, and the package was assigned under their authority. | `requestId`, `packageId`, `identityId`, `assignmentId`, `bindings`, `memberships`, `skipped`, `expiresAt`, `note` | | `package:request-denied` | An approver refused a request. | `requestId`, `packageId`, `identityId`, `note` | | `package:request-cancelled` | The requester withdrew a pending request. | `requestId`, `packageId` | | `package:extend` | `packages.extend` moved the end of an assignment and everything it created. Lengthening is a new grant, so reviewers may want to see it. | `packageId`, `packageName`, `identityId`, `previousExpiresAt`, `expiresAt` | ## Package rules [#package-rules] A *package rule* (`autoAssign`) gives a package to everyone who matches it; the reconciler applies it. These events follow [automatic assignment](/docs/guides/privileged-access/automatic-assignment). Subscribe to `package:auto-*` and alert on the `deny` ones: they mean a rule needs a person's attention. | Event | What happened, and why you would care | Metadata | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `package:auto-rule` | A rule was set, changed, taken over (`owner`), re-authored by a contents change, or cleared (actor: the administrator). Rule changes can grant access to many people at once. | `packageId`, `packageName`, `change`, `revision`, `ownerId`, `previousOwnerId`, `authorityId`, `include`, `exclude`, `graceMs`, `maxGrants`, `maxRemovals`, `kept` | | `package:auto-confirm` | Held-back rule changes were approved for a day (actor: the confirmer or `deployment-operator`). This is the human sign-off on a large change. | `packageId`, `packageName`, `grants`, `removals`, `until` | | `package:auto-assign` | The reconciler assigned, refreshed, or restored an automatic assignment (actor `deployment-operator`). `matchedBy` says which clause matched. | `trigger`, `revision`, `ownerId`, `requestedBy`, `identityId`, `mode`, `reason`, `bindings`, `memberships`, `removedBindings`, `removedMemberships`, `skipped`, `matchedBy` | | `package:auto-ending` | An automatic holder stopped matching and the grace period started. Access ends at `endsAt` unless they match again. | `trigger`, `identityId`, `endsAt`, `graceMs` | | `package:auto-revoke` | The reconciler removed an automatic assignment, because the person no longer matches or the rule was cleared. | `trigger`, `identityId`, `reason` (`no-longer-matches` or `rule-cleared`), `bindings`, `memberships` | | `package:auto-failed` | A change could not be applied, for example because of a separation-of-duties conflict. Recorded once per new problem, outcome `deny`. | `identityId`, `change`, `code`, `message` | | `package:auto-suspended` | A rule stopped adding access: the rule is invalid, the owner is inactive, their authority was revoked, or they lack rights. Outcome `deny`; someone must take the rule over. | `reason`, `detail` | | `package:auto-resumed` | A suspended rule runs again. | `previousReason` | | `package:auto-braked` | An unattended run held back more grants or removals than the rule allows. Outcome `deny`; someone with the rights to assign the package must confirm or fix the rule. | `direction`, `planned`, `threshold` | ## Governance [#governance] These events come from the [governance](/docs/guides/governance) features: invariants (guardrails checked on a schedule), agreements (terms of use), [configuration as code](/docs/guides/privileged-access/config-as-code), certification campaigns (periodic access reviews), and access requests for roles. Keep them for audits; alert on `invariant:broken`. | Event | What happened, and why you would care | Metadata | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `invariant:broken` | `checkInvariants` found an *access invariant* (a rule such as "contractors never approve payments") newly failing or with new violators. Outcome `deny`, actor `deployment-operator`. Page someone. | `name`, `mode`, `violations` (identity IDs), `error` when it could not be evaluated | | `invariant:restored` | `checkInvariants` found a previously failing invariant passing again (actor `deployment-operator`). Close the alert. | `name`, `mode` | | `agreement:accept` | A member accepted a version of a terms-of-use agreement with `agreements.accept`. This is the acceptance record auditors ask for. | `name`, `version` | | `config:apply` | A configuration document was applied. Compare `changed` with the reviewed pull request. | `prune`, the change summary (`create`, `update`, `delete`, `unchanged`), `changed` | | `certification:review` | A manager recorded decisions on their items of a *certification campaign* (a periodic keep-or-revoke review) with `certifications.review`. | `recorded`, `keep`, `revoke` | | `certification:remind` | `certifications.remind` emailed reviewers who still have undecided items. | `reminded`, `pending` | | `certification:auto-close` | `closeOverdueCertifications` closed and applied a due campaign (actor `deployment-operator`). Each removed binding is also recorded as `iam:bindings:delete`. | the outcome counts: `kept`, `revoked`, `already-removed`, `revocation-failed` | | `access-request:approve` | A reviewer approved a role access request, creating or refreshing the bindings. | `requesterId`, `roleIds`, `bindingIds` | | `access-request:deny` | A reviewer denied a role access request. | `requesterId`, `roleIds` | ## Audit and delivery [#audit-and-delivery] These events concern the audit log itself, sign-in failures, and webhook testing. | Event | What happened, and why you would care | Metadata | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `audit:prune` | `pruneAudit` deleted the oldest part of a tenant's audit chain and left this checkpoint so the rest still verifies (actor `deployment-operator`). Archive before you prune. | `deleted`, `before`, `prunedThroughSequence`, `prunedThroughHash` | | `auth:signin:fail` | A wrong password, factor, or recovery code was presented for a real account. Recorded in its own transaction, so it survives the refused sign-in. Use it for brute-force alerting. | `reason`, `ip`, `userAgent` | | `webhook:ping` | Not an audit event: `webhooks.ping` sends it to one endpoint so you can test delivery end to end. | none | ## Next steps [#next-steps] - [Webhooks](/docs/guides/events/webhooks): Subscribe an endpoint to these events, with filters and signatures. - [Scheduling](/docs/guides/governance/scheduling): The scheduled jobs behind the events recorded by deployment-operator. # Webhooks (/docs/guides/events/webhooks) > Signed HTTPS deliveries of audit events with outcome and resource filters, retries with backoff, delivery history, and redelivery. Security tools, alerting, and other services need to hear about access changes as they happen, and they usually live outside your application. Polling the audit log is slow and wasteful; a callback that can be lost or forged is worse. A webhook subscription sends a tenant's audit events to an HTTPS endpoint: a SIEM, an alerting channel, a ticketing system, or your own service. Each delivery is: * queued in the same transaction as the event, so it cannot be lost; * signed, so the receiver can prove it came from Better IAM; * retried with backoff until the endpoint accepts it (25 attempts by default). ## Create a subscription [#create-a-subscription] `webhooks.create` subscribes an endpoint to the events whose action matches your patterns. It returns the subscription (`webhook`) and its signing `secret`, which your endpoint needs to verify deliveries: ```ts title="A SIEM feed" const { webhook, secret } = await iam.api.webhooks.create(credential, { tenantId, url: 'https://hooks.example.com/iam', events: ['iam:identities:*', 'iam:bindings:*', 'auth:session:create'], description: 'SIEM feed', }); // Store `secret` now: it is returned only once. ``` * `iam:webhooks:create`, `read`, `update`, and `delete` gate the API group. Creating, updating, rotating, and deleting require recent authentication. * A subscription belongs to one tenant and receives that tenant's events. Root administrators may create a subscription with `scope: 'subtree'` on any tenant to receive its descendants' events as well. Ordinary administrators cannot, because parent membership grants nothing in child tenants. * A tenant holds at most 50 subscriptions (`LIMIT_EXCEEDED`), and a plan limit on webhooks can lower that. ### Filters [#filters] Endpoints should receive only what they act on; a security feed that gets every sign-in is noise. Action patterns choose which events a subscription wants, and `outcomes` and `resources` narrow it further: ```ts title="Only denials on IAM resources" const { webhook } = await iam.api.webhooks.create(credential, { tenantId, url: 'https://siem.example.com/iam', events: ['*'], outcomes: ['deny'], resources: ['iam/*'], }); ``` Useful subscriptions: `binding:*` to alert on elevation, `invariant:*` for broken guardrails, `package:auto-*` for package rules, and `auth:signin:fail` for brute-force attempts. The [lifecycle events](/docs/guides/events/lifecycle-events) page explains each. ### Manage subscriptions [#manage-subscriptions] | Call | Permission | What it does, and when to use it | | ----------------------------------------------------------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`webhooks.list`](/docs/reference/api/webhooks#list), [`get`](/docs/reference/api/webhooks#get) | `iam:webhooks:read` | Return subscriptions without their secret, for a settings page. | | [`webhooks.update`](/docs/reference/api/webhooks#update) | `iam:webhooks:update` | Change the URL, event patterns, description, filters, or `active` flag; `null` clears `outcomes`, `resources`, or the description. Set `active: false` to pause during endpoint maintenance: a paused subscription drops its pending deliveries rather than piling them up. | | [`webhooks.rotateSecret`](/docs/reference/api/webhooks#rotatesecret) | `iam:webhooks:update` | Issue a new signing secret, returned once. Use it on a schedule or when a secret may have leaked. | | [`webhooks.ping`](/docs/reference/api/webhooks#ping) | `iam:webhooks:update` | Queue a synthetic `webhook:ping` event, to test a new endpoint end to end before real events arrive. | | [`webhooks.listDeliveries`](/docs/reference/api/webhooks#listdeliveries) | `iam:webhooks:read` | Return the newest deliveries (100 by default, at most 1000) with their status, to debug a failing endpoint. | | [`webhooks.redeliver`](/docs/reference/api/webhooks#redeliver) | `iam:webhooks:update` | Queue the event behind an earlier delivery again, after an outage or to replay an event. | | [`webhooks.delete`](/docs/reference/api/webhooks#delete) | `iam:webhooks:delete` | Remove the subscription and its pending deliveries. | ## Delivery [#delivery] A delivery must survive a crash between the change and the HTTP call, so it is not sent from the request. Deliveries travel through the encrypted, transactional delivery outbox as `kind: 'webhook'` messages. They are written in the same transaction as the event and sent afterwards by `iam.auth.dispatchOutbox()` (CLI `outbox`). Schedule it from your worker, every minute for example. The built-in transport POSTs the JSON body with these headers: | Header | Value | | ------------------------ | -------------------------------------------------------------- | | `Content-Type` | `application/json` | | `User-Agent` | `better-iam-webhooks/1` | | `X-Better-IAM-Event` | The event type (audit action), for example `iam:groups:create` | | `X-Better-IAM-Delivery` | The outbox message ID; deduplicate on it | | `X-Better-IAM-Webhook` | The subscription ID | | `X-Better-IAM-Timestamp` | Unix seconds at signing time | | `X-Better-IAM-Signature` | `v1=` followed by hex HMAC-SHA256 of `${timestamp}.${body}` | The body: ```json title="Webhook body" { "id": "...", "type": "binding:activate", "tenantId": "...", "actorId": "...", "resourceId": "...", "outcome": "allow", "timestamp": 1790000000000, "metadata": { "activationId": "...", "roleId": "...", "expiresAt": 1790003600000, "justification": "INC-4211" }, "sequence": 1842, "hash": "9f2c..." } ``` Its fields are `id`, `type` (the audit action), `tenantId`, `actorId`, `resourceId`, `outcome`, and `timestamp`, plus `originalActorId`, `impersonatorId`, `rootOverride`, `metadata`, `sessionContext`, `sequence`, and `hash` when present. `impersonatorId` names the administrator behind an impersonation ("view as") session while `actorId` stays the member. `sessionContext` says which session acted (its ID and kind, and the role or trust behind a temporary credential). `sequence` and `hash` locate the event in the tenant's audit chain, so a consumer can detect gaps and verify what it stored. See [audit chain](/docs/guides/events/audit-chain). ### Retries [#retries] Endpoints go down for deploys and outages. Retries mean a short outage loses nothing: * A non-2xx response or a timeout (`events.webhookTimeoutMs`, ten seconds by default) counts as a failed attempt. Redirects are never followed. * Failed messages retry with exponential backoff starting at thirty seconds and capped at one hour. * After `authentication.maxDeliveryAttempts` (25 by default) the message is abandoned with `failedAt` and `lastError` set. * Delivery is at least once. Endpoints must deduplicate, by the event `id` or the delivery header. ### Custom transport [#custom-transport] Some deployments cannot make outbound HTTP calls from the worker, or want deliveries on their own queue with its own retry policy. Supply `events.deliverWebhook` to replace the built-in HTTP transport. It receives `{ id, tenantId, webhookId, url, event, body, headers }` with the signature already computed, and must throw to signal failure so the outbox retries. `events.webhookTimeoutMs` sets the built-in transport's timeout. ```ts title="iam.ts" const iam = betterIam({ // ... events: { webhookTimeoutMs: 5_000, async deliverWebhook(delivery) { await queue.send({ url: delivery.url, body: delivery.body, headers: delivery.headers }); }, }, }); ``` ## Verify deliveries [#verify-deliveries] Your endpoint URL is not a secret, so anyone could POST a fake "role granted" event to it. Every delivery is therefore signed with the subscription's secret, and the timestamp is part of the signature so an old delivery cannot be replayed later. `verifyWebhookSignature` from `better-iam` checks both; reject any request it refuses. The signing secret is returned once by `create` and `rotateSecret`; only its sealed form is stored. Pending deliveries are signed with the secret current at delivery time, so a rotation applies to messages still in the queue. ```ts title="app/api/iam-webhook/route.ts" import { verifyWebhookSignature } from 'better-iam'; export async function POST(request: Request) { const body = await request.text(); const valid = verifyWebhookSignature({ secret: process.env.IAM_WEBHOOK_SECRET!, timestamp: request.headers.get('x-better-iam-timestamp')!, body, signature: request.headers.get('x-better-iam-signature')!, }); if (!valid) return new Response('invalid signature', { status: 401 }); const event = JSON.parse(body); await handleOnce(event.id, event); // deduplicate: delivery is at least once return new Response(null, { status: 204 }); } ``` Verification uses a constant-time comparison and rejects timestamps more than five minutes from the current time by default (`toleranceSeconds`). Verify the raw body exactly as received, before parsing it. Next.js applications can use `createWebhookHandler({ secret, onEvent })` from `better-iam/next/edge` instead. It verifies against every configured secret (so you can list the new and the previous one while rotating), rejects stale timestamps and bodies over 1 MiB before parsing, and answers 500 when `onEvent` throws so the delivery is retried. See [Next.js](/docs/frameworks/nextjs). ## Delivery history and redelivery [#delivery-history-and-redelivery] When an endpoint was down longer than the retries last, or a bug dropped events on the receiving side, you need to see what failed and send it again: ```ts title="After an endpoint outage" const history = await iam.api.webhooks.listDeliveries(credential, { tenantId, webhookId: webhook.id }); for (const delivery of history.filter((item) => item.status === 'failed')) await iam.api.webhooks.redeliver(credential, { tenantId, webhookId: webhook.id, deliveryId: delivery.id, }); ``` * `listDeliveries` returns the newest deliveries with attempt counts, timestamps, the last error, the audit `eventId` they carried, and a `pending`, `delivered`, or `failed` status. Payloads are never returned. * `redeliver({ webhookId, deliveryId })` queues the event behind an earlier delivery again, rebuilt from the audit record and signed with the subscription's current secret at delivery time. Use it after an endpoint outage abandoned deliveries, or to replay an event. Endpoints must still deduplicate by event `id`. * `ping` and `redeliver` refuse a paused subscription (`INVALID_TRANSITION`). Redelivery fails with `NOT_FOUND` once the audit event was pruned. * The retention sweep deletes delivered and abandoned messages older than `deliveryRetentionMs` (30 days by default), which also bounds the delivery history and what can be redelivered. ## What webhooks carry [#what-webhooks-carry] Webhook endpoints receive audit metadata only: identifiers, action names, outcomes, and the metadata a mutation recorded. Tokens, secrets, and passwords never appear in audit records and therefore never reach a webhook. The deployment `secret` seals the signing secrets and undelivered payloads; rotating it re-seals them (see [secrets](/docs/operations/deployment/secrets)). - [webhooks API reference](/docs/reference/api/webhooks): Every method with its HTTP route. - [Lifecycle events](/docs/guides/events/lifecycle-events): Events worth subscribing to, with their metadata. # Access paths (/docs/guides/governance/access-paths) > Tell a denied person what they could do themselves, such as step up to MFA, accept terms, activate a role, or request a package. "Access denied" is a dead end. The person files a ticket, an administrator investigates, and often the answer was something the person could have done alone: sign in with their second factor, accept the new terms, or activate a role they are already eligible for. *Access paths* answer the question "how can I get access?" at the moment of the denial, with the options the person can take on their own: * step up to MFA; * accept pending terms of use; * activate an eligible role; * request a requestable access package. Every option is verified before it is offered, so the list never promises access that would still be refused. ## Find the paths [#find-the-paths] Call `accessPaths.find` right after an authorization check fails, with the same action and resource, and turn each path into a button or a hint: ```ts title="After a denial" const check = await iam.authorize({ token, tenantId, action: 'documents:delete', resource }); if (!check.allowed) { const { paths } = await iam.api.accessPaths.find( { token }, { tenantId, action: 'documents:delete', resource }, ); for (const path of paths) { if (path.kind === 'mfa') showStepUp(); if (path.kind === 'accept-agreements') showTerms(path.agreements); // then agreements.accept if (path.kind === 'activate') offerActivation(path.bindingId, path.role, path.requireJustification); // bindings.activate if (path.kind === 'request-package') offerRequest(path.package); // packages.request } if (!paths.length) showAskAnAdministrator(); } ``` `accessPaths.find({ tenantId, action, resource })` returns `{ allowed, reason, paths }`. When the request is already allowed, `paths` is empty. When it is denied, `paths` lists what the person could do alone, and an empty list means only an administrator can help. | Kind | Fields | What the person does next | | ------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | `mfa` | none | Step up to a second factor ([MFA](/docs/guides/authentication/mfa)). | | `accept-agreements` | `agreements`: `id`, `name`, and `version` of each required agreement owed | Accept them with [`agreements.accept`](/docs/guides/governance/agreements#accept). | | `activate` | `bindingId`, `role`, `requireApproval`, `requireJustification`, `requireMfa`, `maxActivationMs` | Activate the [eligible binding](/docs/guides/privileged-access/elevation#activate). | | `request-package` | `package` (`id`, `name`, `description`), `requireJustification` | Ask for the [package](/docs/guides/privileged-access/access-packages#self-service-requests). | The approval requirements are reported so the UI can say "your request goes to an approver" before the person asks. `requireMfa` on an `activate` path gates the activation itself, not the grants it brings, so a person holding a password-only session may need to step up before activating. ## How paths are verified [#how-paths-are-verified] A suggestion that does not work is worse than none. So each candidate is applied inside a transaction that is always rolled back, and the ordinary decision runs again; only candidates that turn the denial into an allow are offered. Nothing is saved. * **MFA.** The request is re-evaluated as if the session were MFA-verified. * **Terms.** The required agreements the person owes are recorded as accepted, then removed again. * **Eligible roles.** Each eligible binding the person holds, directly or through a group, is simulated as activated. It is offered only when the person also holds `iam:bindings:activate` for its role. * **Packages.** Each requestable package is simulated as assigned. It is offered only when the person holds `iam:packages:request` on it. At most 50 eligible bindings and 50 requestable packages are considered. Like `authorize`, a denial's `reason` is always `ACCESS_DENIED`, so the call does not reveal which rule refused. The call needs only the person's own ordinary session of the tenant. Assumed-role sessions and sessions of another tenant are refused with `ACCESS_DENIED`, and impersonation sessions with `IMPERSONATION_RESTRICTED`. An action the catalog does not know fails with `INVALID_ACTION`. ## In React and Vue [#in-react-and-vue] In a React or Vue application, the `useAccessPaths` hook runs the same call for a component, so a button can explain how to get access instead of disappearing. It returns `allowed`, `reason`, `paths`, `status`, `error`, and `refresh`, and takes `enabled: false` to skip loading. ```tsx title="components/delete-button.tsx" import { useAccessPaths } from 'better-iam/react'; export function DeleteButton({ tenantId, documentId }: { tenantId: string; documentId: string }) { const { allowed, paths, status } = useAccessPaths({ tenantId, action: 'documents:delete', resource: { type: 'document', id: documentId }, }); if (status === 'loading') return null; if (allowed) return ; if (!paths.length) return

Ask an administrator for access.

; return (
    {paths.map((path, index) => (
  • {path.kind === 'mfa' && 'Sign in with your second factor'} {path.kind === 'accept-agreements' && 'Accept the terms of use'} {path.kind === 'activate' && `Activate ${path.role.name}`} {path.kind === 'request-package' && `Request ${path.package.name}`}
  • ))}
); } ``` The Vue composable of the same name, from `better-iam/vue`, accepts its input as a ref or getter and returns computed refs. See [React](/docs/frameworks/react) and [Vue](/docs/frameworks/vue). > **Advisory, like authorize.** Access paths are advisory: advice for the UI. The server still calls `iam.require` (or `authorize`) immediately before performing the protected operation. - [accessPaths.find](/docs/reference/api/access-paths#find): Signature and HTTP route. - [Just-in-time elevation](/docs/guides/privileged-access/elevation): The eligible bindings behind activate paths. # Terms of use (/docs/guides/governance/agreements) > Versioned agreements such as acceptable-use policies that members accept, with enforcement through ordinary policy statements. Many organizations must show that people agreed to the rules before they got access: an acceptable-use policy, an NDA, data-handling rules for customer data. Emailing a PDF proves nothing, and tracking acceptance in a separate tool means access and agreement drift apart. Agreements put the terms next to the access. An agreement is a versioned text a tenant asks its members to accept. Better IAM records who accepted which version and exposes that to every authorization decision, so enforcing it is an ordinary policy statement, such as "deny documents until the terms are accepted", that combines with everything else. ## Publish an agreement [#publish-an-agreement] `agreements.create` (`iam:agreements:manage`) publishes an agreement at version 1. Members are asked to accept it from then on. ```ts await iam.api.agreements.create(credential, { tenantId, name: 'Acceptable use', content: 'Use company systems for work. Report incidents within 24 hours.', reacceptAfterDays: 365, }); ``` A tenant holds at most 50 agreements. ### Manage agreements [#manage-agreements] * `agreements.update` (`iam:agreements:manage`) edits an agreement. With `newVersion: true`, the change becomes the next version and everyone must accept again; use it when the rules changed. Without it, for a typo fix, existing acceptances stay valid. `reacceptAfterDays: null` removes the lapse. * `agreements.list` (`iam:agreements:read`) lists the tenant's agreements with their current version. * `agreements.status({ tenantId, agreementId })` (`iam:agreements:read`) reports who accepted the current version and which active people still owe it (an outdated version, a lapsed acceptance, or none). Use it for compliance reports and to chase people who have not accepted. * `agreements.delete` (`iam:agreements:manage`) removes an agreement together with its acceptances. ## Accept [#accept] Members accept for themselves, and need no permission for it: ```ts const mine = await iam.api.agreements.listMine({ token }, { tenantId }); for (const agreement of mine.filter((item) => item.required && !item.accepted)) await iam.api.agreements.accept( { token }, { tenantId, agreementId: agreement.id, version: agreement.version }, ); ``` * `agreements.listMine({ tenantId })` returns every agreement of the tenant with its text and whether the caller has `accepted` its current version. Use it to show the terms. * `agreements.accept({ tenantId, agreementId, version })` records acceptance of the version the person was shown. Passing the version means nobody accepts text they never saw: once the agreement changed, the call fails with `VERSION_CONFLICT`. It is audited as `agreement:accept`. * Both need an ordinary session of the tenant. An administrator using impersonation cannot accept for someone (`IMPERSONATION_RESTRICTED`), and service accounts accept nothing (nothing is pending for them). The console shows members the required agreements they owe at the top of every page with an "I accept" button, and its Terms of use page publishes, edits, and tracks them. ### In React and Vue [#in-react-and-vue] To show the same banner in your own application, use the `useAgreements` hook. It returns the person's `agreements`, the `pending` ones (required and not accepted in their current version), and an `accept` function that records acceptance and reloads. ```tsx title="components/terms-banner.tsx" import { useAgreements } from 'better-iam/react'; export function TermsBanner({ tenantId }: { tenantId: string }) { const { pending, accept } = useAgreements({ tenantId }); if (!pending.length) return null; return (
{pending.map((agreement) => (

{agreement.name}

))}
); } ``` The Vue composable of the same name, from `better-iam/vue`, accepts its input as a ref or getter and returns the same fields with the data as refs. Both take `enabled: false` to skip loading. See [React](/docs/frameworks/react) and [Vue](/docs/frameworks/vue). ## Enforce with a policy [#enforce-with-a-policy] Recording acceptance is only half the job; access should wait for it. Instead of a special switch, Better IAM gives policies two facts about each person, so you decide exactly which access depends on which terms. Every evaluation for a person in their own tenant carries these context keys: * `principal.agreements`: the names of the agreements accepted in their current version. * `principal.pendingAgreements`: how many required ones are still owed. A deny statement holds back access until every required agreement is accepted: ```json title="Hold back documents until the terms are accepted" { "effect": "deny", "actions": ["documents:*"], "resources": ["*"], "conditions": { "NumericGreaterThan": { "principal.pendingAgreements": 0 } } } ``` A condition on an allow statement can grant something only to people who accepted an optional agreement, such as a beta program: ```json { "ArrayContains": { "principal.agreements": ["Beta program"] } } ``` When a person is denied because of pending terms, [access paths](/docs/guides/governance/access-paths) report an `accept-agreements` path listing what to accept. ## Agreements as code [#agreements-as-code] Agreements are part of [configuration as code](/docs/guides/privileged-access/config-as-code): `name`, `content`, `url`, `required` (default true), and `reacceptAfterDays`, matched by name regardless of case. A changed `content` publishes a new version, so everyone accepts the new text; other edits keep acceptances. Changing them needs `iam:agreements:manage`. Publishing agreements is guarded by enforced invariants ([change safety](/docs/guides/governance/change-safety#access-invariants)). Members accepting agreements is not, so schedule the invariant monitor if an invariant depends on them. - [agreements API reference](/docs/reference/api/agreements): Every method with its HTTP route. - [Policy conditions](/docs/guides/authorization/conditions): Operators and context keys, including the agreement keys. # Certifications (/docs/guides/governance/certifications) > Access certification campaigns where reviewers or managers keep or revoke each binding, with reminders, usage-based recommendations, and auto-close. Regulations and customers often require proof that someone regularly checks who has access to what, and removes what is no longer needed. Doing that in a spreadsheet is slow, and the decisions rarely make it back into the system. An access certification campaign turns the review into a recorded decision. It takes a snapshot of the role bindings under review and asks reviewers to *keep* or *revoke* each one. When it closes, it applies the result: revoked bindings are removed and every item records what happened. Reviewers can be named people or each person's manager, recommendations put usage evidence beside every item, and campaigns can close themselves when they are due. ## Create a campaign [#create-a-campaign] Open one campaign per review cycle, such as a quarterly review of the administrator roles. `certifications.create` opens it and needs `iam:certifications:manage`. It returns the campaign with `items`, the number of bindings it covers. ```ts title="Quarterly review of the admin roles" const campaign = await iam.api.certifications.create(credential, { tenantId, name: 'Q3 admin review', roleIds: [admin.id, billingAdmin.id], reviewerMode: 'manager', reviewerIds: [securityLead.id], // items without an active manager go here dueAt: Date.parse('2026-10-15T17:00:00Z'), autoClose: true, undecided: 'revoke', }); // campaign.items: how many bindings the campaign covers ``` The campaign snapshots the live bindings of every non-protected role, or of the listed roles and subject type. It covers at most 5000 bindings (`LIMIT_EXCEEDED` otherwise; narrow it by role or subject type). Each item records the role, the subject, the binding's end, and whether it is eligible. When email delivery is configured, each reviewer receives a `certification-review` email so they know they have work. It is queued in the campaign's transaction and carries `campaignId`, `campaignName`, `items` (their own item count), and `dueAt` when set. ## Decide [#decide] Reviewers go through their items and record keep or revoke, optionally with a note that explains the decision. `certifications.decide` records decisions in batches of 1 to 200 items and returns how many it `recorded`: ```ts await iam.api.certifications.decide(reviewerCredential, { tenantId, campaignId: campaign.id, decisions: [ { itemId: 'item-1', decision: 'keep' }, { itemId: 'item-2', decision: 'revoke', note: 'Moved to finance in July' }, ], }); ``` * `decide` needs `iam:certifications:review`. When the campaign names `reviewerIds`, only they may decide. * Nobody decides on their own access, directly or through a group (`SELF_REVIEW`), because a review of yourself is no review. * A decision can be changed until the campaign closes; a closed campaign refuses decisions (`CONFLICT`). To follow progress, `certifications.list` lists campaigns (optionally by `status`) and `certifications.get` returns one campaign with its items. With `mine: true`, `get` leaves out items about the caller's own access, which they may not decide. Both report `progress` (`total`, `decided`, `keep`, `revoke`) and need `iam:certifications:read`. ### Manager reviews [#manager-reviews] The best reviewer for a person's access is usually their manager. With `reviewerMode: 'manager'`, each person's items go to their active manager (`Identity.managerId`, which SCIM can maintain). Items without one, and group items, go to the named reviewers, or to anyone holding `iam:certifications:review` when none are named. Managers do not need an administrator role for this: * `certifications.listMine({ tenantId })` returns the open campaigns with items assigned to the caller, which is all a manager's review screen needs. * `certifications.review({ tenantId, campaignId, decisions })` records the manager's decisions. It needs no certification permission, only an ordinary session of the tenant (not impersonation) and the assignment, and is audited as `certification:review`. * Through `decide`, a manager-assigned item may be decided only by that manager or by a holder of `iam:certifications:manage`. * Each reviewer is emailed once with their own item count. ### Recommendations [#recommendations] Reviewers approve almost everything when they have nothing to go on. Recommendations give them evidence. `roleMining.reviewRecommendations({ tenantId, campaignId, unusedDays? })` (`iam:analysis:read`) suggests a decision for every item, with a reason: ```ts const { usageComplete, recommendations } = await iam.api.roleMining.reviewRecommendations(credential, { tenantId, campaignId: campaign.id, }); // recommendations: [{ itemId, recommendation: 'keep' | 'revoke' | 'none', basis, reason, lastUsedAt }] ``` * `revoke` when the account is disabled, deleted, or expired, or when the person did not use the role within `unusedDays` (90 by default); `keep` when they did. * The evidence (`basis`) is recorded [access usage](/docs/guides/governance/usage-and-mining#access-usage) once it covers the whole window (`usageComplete`), otherwise the person's last sign-in, or `status` for an inactive account. * Group items and service accounts without usage data get `none`. Reviewers still decide. The console's campaign page shows the suggestion and its reason next to each open item. ## Remind [#remind] Reviews stall when reviewers forget. `certifications.remind({ tenantId, campaignId })` (`iam:certifications:manage`) emails every reviewer who still has undecided items a `certification-reminder` with their pending count, and returns `{ reminded, pending }`. Send one a few days before `dueAt`. It needs an email delivery callback (`DELIVERY_REQUIRED` otherwise), refuses closed campaigns, and is audited as `certification:remind`. `renderDeliveryMessage` from `better-iam/auth/templates`, the built-in email renderer, renders both the `certification-review` and `certification-reminder` emails. Give it a `links.certification({ tenantId, campaignId })` function that returns your campaign page URL, and the emails get a button to it. ## Close [#close] Closing is what makes the review count. `certifications.close({ tenantId, campaignId })` (`iam:certifications:manage`, recent authentication) applies the campaign: * Revoked items, and undecided ones when `undecided: 'revoke'`, are removed under the closer's grant authority. Each revocation is audited as `iam:bindings:delete`. * Every item records its outcome: `kept`, `revoked`, `already-removed` (the binding was gone already), or `revocation-failed` (a binding a higher authority granted, left for that administrator). The result carries the counts per outcome. * Enforced invariants ([change safety](/docs/guides/governance/change-safety#access-invariants)) guard closing a campaign like any other access change. Closed campaigns keep their record, with every decision and outcome, as evidence for auditors. `certifications.delete` removes a campaign and its items once you no longer need it; it only accepts closed campaigns. ### Auto-close [#auto-close] A campaign with `autoClose: true` (and `dueAt`) closes itself when it is due. The deployment job `iam.closeOverdueCertifications({ tenantId? })`, or CLI `close-certifications`, finds every due campaign and applies it: * Each campaign closes in its own transaction, under the creator's grant authority. Revocations the creator could not make, or every revocation when the creator no longer exists, are reported as `revocation-failed`. * Each close is audited as `certification:auto-close` (with the outcome counts) by `deployment-operator`, plus one `iam:bindings:delete` per removed binding. * It returns `{ closed, skipped }`, where `skipped` counts auto-closing campaigns that are not due yet. It needs no credential and no email transport. ```sh title="Scheduler, daily" better-iam close-certifications --config better-iam.config.mjs ``` Enforced invariants do not guard scheduled jobs such as auto-closing campaigns; the [invariant monitor](/docs/guides/governance/change-safety#monitor-and-alert) reports what they break. - [certifications API reference](/docs/reference/api/certifications): Every method with its HTTP route. - [Sharing and reviews recipes](/docs/guides/recipes/sharing-and-reviews): Copy-ready review workflows. # Change safety (/docs/guides/governance/change-safety) > Preview who gains and loses what before editing a role or policy, and enforce access invariants that no change may break. Access changes are risky because their effect is hard to see. Adding one action to a role changes access for everyone who holds it, directly, through a group, or through a role that inherits it. Removing one can break a team's work. And some things must never happen however roles evolve: a contractor approving payments, the on-call team losing the ability to restart production. Two tools make changes safe: * An impact preview answers "who gains or loses what if I make this change?" before you make it. * An access invariant writes down a line that must hold whatever roles and policies say. It reports when the line is crossed and, when enforced, refuses any change that would cross it. ## Impact preview [#impact-preview] Use a preview before editing a role or policy that many people hold, or before deleting a role, to see exactly whose access changes on the resources you care about. ```ts title="Preview a role edit" const preview = await iam.api.impact.preview(credential, { tenantId, change: { role: { roleId, permissions: ['payments:read', 'payments:approve'] } }, resources: [{ type: 'ledger', id: 'main' }], }); // preview.identities: who gains and loses which actions on each resource // preview.invariants.broken: guardrails the change would break ``` `impact.preview({ tenantId, change, resources, actions?, assumeMfa? })` needs `iam:policies:simulate`. `change` is exactly one of: | Change | What it simulates | Permission it also needs | | ------------------------------------ | ---------------------------------------------------------------------------- | ------------------------ | | `{ role: { roleId, ...update } }` | A `roles.update`: new `permissions`, `document`, `policyIds`, or `inherits`. | `iam:roles:update` | | `{ policy: { policyId, document } }` | Replacing a policy's document. | `iam:policies:update` | | `{ deleteRole: roleId }` | Deleting the role. | `iam:roles:delete` | ### How it works [#how-it-works] The server makes the change the way the real call would, inside a transaction it always rolls back. The same validation, the same permission on the item, and the same edit rights apply: a role others inherit cannot be deleted, and a lower authority's role cannot be edited. Nothing is saved. * **Who is evaluated.** The affected roles are the changed role, or the roles attaching the policy, plus every role inheriting them. Their holders are active identities with a binding to them, directly or through a live group membership, up to 200 (`truncated` is true when there were more). * **What is evaluated.** Each holder is checked before and after the change against each of the 1 to 10 `resources`, for every known action or only the `actions` you pass (up to 200). `assumeMfa` evaluates holders as if they had completed MFA, to see the most they could reach. * **Everything counts.** The ordinary evaluator runs, so conditions, authority ceilings, boundaries, access windows, and just-in-time eligibility all count, exactly as they would in production. The result names the affected `roles` and how many holders were `evaluated`, and lists, per holder and resource, the actions `gained` and `lost`, with `gainedTotal` and `lostTotal`. Its `invariants` lists the [access invariants](#access-invariants) the change would newly break (`broken`, with the new violations) or make pass again (`fixed`). The console's Change impact page previews role permissions, policy documents, and role deletion. ## Access invariants [#access-invariants] Roles change all the time, and so do the people who edit them. Some rules should hold whatever anyone does: "contractors can never approve payments", "the on-call group can always restart production". An access invariant states such a rule as a check Better IAM can run: *these people*, *this action*, *this resource*, *must be denied* (or *must be allowed*). ```ts title="A guardrail" await iam.api.invariants.create(credential, { tenantId, name: 'Contractors never approve payments', subject: { attribute: { name: 'contractor', value: true } }, action: 'payments:approve', resource: { type: 'ledger', id: 'main' }, expect: 'deny', mode: 'enforce', }); ``` The calls: * `invariants.create` and `invariants.update` save a rule (`iam:invariants:manage`) and return it with its current result, so you see immediately whether it already holds. * `invariants.run({ tenantId, invariantId? })` (`iam:invariants:read`) evaluates one or all rules against the current configuration with the ordinary evaluator. Each result has `passed`, the `violations` (the person and the decision reason), and an `error` when the rule can no longer be evaluated, for example because its group or resource is gone. * `invariants.list` returns the rules with their last monitored outcome, and `invariants.delete` removes one. A tenant holds at most 100 invariants. When running or monitoring, at most 500 people are evaluated per invariant. ### Enforcement [#enforcement] A monitored rule tells you after the fact. An enforced rule (`mode: 'enforce'`) stops the change instead. It is evaluated before and after every operation that can change access: * role and policy edits and deletions; * binding creation and deletion, just-in-time activation and approval; * group membership changes, identity creation and attribute changes; * package assignment and approval, configuration apply, access-request review; * relationship and resource changes and deletions, boundary and authority changes, root grants; * closing certification campaigns, publishing agreements, and `roleMining.apply`. Enforcement evaluates every subject, not only the first 500. When the operation newly breaks a rule, or leaves it impossible to evaluate (for example by deleting the group or resource it names), the operation is refused with `INVARIANT_VIOLATION` (409) and its transaction rolls back. > **Existing violations do not block unrelated work.** Violations that already existed are reported but do not block unrelated work, so you can switch a rule to `enforce` while it is still broken and fix the violations at your own pace. Only changes that add violations are refused. Changes made outside an operation are not guarded: scheduled jobs (purge, package reconciliation, auto-closing campaigns), inbound SCIM provisioning, and members accepting agreements. The monitor below reports what they break. The console's Access invariants page lists rules with their status, toggles enforcement, and creates new ones. ### Monitor and alert [#monitor-and-alert] `iam.checkInvariants({ tenantId? })` (CLI `better-iam monitor-invariants`) is the scheduled check that tells you when a rule starts failing. It is a deployment operation that evaluates every organization's invariants, or one tenant's: * It stores the outcome on each rule (`lastCheck`: `at`, `passed`, and the violating identity IDs). * It records `invariant:broken` (with the new violators, outcome `deny`) when a rule starts failing or gains violators, and `invariant:restored` when it passes again, both as `deployment-operator`. * Each change is reported once, so a webhook subscribed to `invariant:*` alerts without repeating itself. Run it hourly, and route the events to your alerting: ```ts await iam.api.webhooks.create(credential, { tenantId, url: 'https://alerts.example.com/iam', events: ['invariant:*'], }); ``` ### Gate CI [#gate-ci] `better-iam check-invariants --tenant ID --fail-on-broken` runs every rule as the `BETTER_IAM_TOKEN` holder (`iam:invariants:read`), prints the result, and exits with `INVARIANTS_BROKEN` when any rule is broken or cannot be evaluated. Run it after `config-apply`, so a deploy that crosses a line fails the pipeline. ```sh BETTER_IAM_TOKEN=... better-iam config-apply --config better-iam.config.mjs --tenant TENANT_ID --input tenant.json BETTER_IAM_TOKEN=... better-iam check-invariants --config better-iam.config.mjs --tenant TENANT_ID --fail-on-broken ``` ### Invariants as code [#invariants-as-code] Invariants are part of [configuration as code](/docs/guides/privileged-access/config-as-code), so they can live in version control next to the roles they protect. In a document, the subject names groups and people portably (`{ "group": "Contractors" }`, `{ "identity": "alice@example.com" }`). Enforced invariants are checked around the whole apply against the rules as they were before it, so relaxing a rule and making the change it forbade take two applies. - [impact.preview](/docs/reference/api/impact#preview): Signature and result type. - [invariants API reference](/docs/reference/api/invariants): create, run, update, list, and delete. # Governance (/docs/guides/governance) > The loop that keeps access correct over time, from measuring usage and mining roles to reviews, guardrails, terms of use, and self-service. Roles and bindings say who *may* do what on the day you set them up. Then people change teams, projects end, roles grow, and exceptions pile up. A year later nobody can say whether the access is still right, and auditors ask anyway. *Governance* is the loop that keeps access right over time. You measure which access is actually used, simplify how it is granted, have the people who know certify it, check each change before making it, hold lines no change may cross, get people to agree to the rules, and help them get what they need without an administrator. ## Questions and the features that answer them [#questions-and-the-features-that-answer-them] Each governance feature answers one question an administrator or auditor asks. Start from the question you have: | Question | Feature | API | | ----------------------------------------------- | ----------------------------- | ------------------------------------------------------ | | Which grants are redundant or could be simpler? | Role mining | `roleMining.suggest` / `apply` | | Who holds access unlike their peers? | Peer outliers | `roleMining.outliers` | | Which access is actually used? | Access usage and right-sizing | `accessUsage` option, `roleMining.usage` / `rightSize` | | Should this person keep this role? | Review recommendations | `roleMining.reviewRecommendations` | | What happens if I edit this role? | Change impact preview | `impact.preview` | | What must never (or always) be possible? | Access invariants | `invariants.*`, `iam.checkInvariants()` | | Have people accepted the rules? | Terms of use | `agreements.*`, `principal.pendingAgreements` | | How can I get access myself? | Self-service access paths | `accessPaths.find`, `useAccessPaths` | The administrator reads above each need a permission: `iam:analysis:read` (role mining, usage, recommendations), `iam:policies:simulate` (impact), `iam:invariants:read`, or `iam:agreements:read`. Access paths and a member's own agreements need only that person's session. The console's Organization section has a page for each feature. ## The loop [#the-loop] ### Measure [#measure] You cannot remove unused access until you know what is used. Turn on [access usage](/docs/guides/governance/usage-and-mining#access-usage) once and let it run for a review period. Every allowed check is counted per person and action, so `roleMining.rightSize` can later show which grants nobody touched. ### Simplify [#simplify] Access granted one person at a time becomes hard to reason about. *Role mining* reads who holds what and proposes simpler grants: redundant bindings to remove, roles to bind to a group once, duplicate roles, and bundles of roles to grant together as an access package. [Peer outliers](/docs/guides/governance/usage-and-mining#peer-outliers) show people whose access differs from their colleagues', often access that outlived a move. ### Review [#review] Auditors want proof that someone checks access regularly. A certification campaign is that check: it asks named reviewers, or each person's manager, to keep or revoke every role binding, and applies the decisions when it closes. [Certifications](/docs/guides/governance/certifications) also suggest a decision for each item from usage evidence. ### Change safely [#change-safely] A role edit can give or take access from many people at once. An impact preview shows who gains and loses what before you make the change. An access invariant is a rule that must hold whatever roles say, such as "contractors never approve payments"; enforced invariants refuse any change that would break them. See [change safety](/docs/guides/governance/change-safety). ### Agree [#agree] Some access should depend on people accepting rules first, such as an acceptable-use policy. [Terms of use](/docs/guides/governance/agreements) are versioned agreements that members accept; an ordinary policy statement holds back access until they do. ### Help people help themselves [#help-people-help-themselves] "Access denied" sends people to an administrator even when they could fix it themselves. When your application refuses something, [access paths](/docs/guides/governance/access-paths) tell the person what they could do about it: step up to MFA, accept terms, activate an eligible role, or request a package. Two related tools live with authorization: separation-of-duties rules, which name roles nobody may hold together, and the access analysis, a scan of a tenant's configuration for risky grants. See [separation of duties](/docs/guides/authorization/separation-of-duties) and [access reviews](/docs/guides/authorization/reviews). ## In this section [#in-this-section] - [Usage and role mining](/docs/guides/governance/usage-and-mining): Record what people use, right-size roles, mine suggestions, and find peer outliers. - [Certifications](/docs/guides/governance/certifications): Campaigns, reviewer modes, reminders, recommendations, and auto-close. - [Change safety](/docs/guides/governance/change-safety): Impact previews and access invariants that refuse changes breaking a guardrail. - [Terms of use](/docs/guides/governance/agreements): Versioned agreements that members accept and policies can require. - [Access paths](/docs/guides/governance/access-paths): Answer "how can I get access?" with options verified by simulation. - [Scheduling](/docs/guides/governance/scheduling): The jobs, CLI commands, and cadences that keep the loop running. # Scheduling (/docs/guides/governance/scheduling) > The jobs, CLI commands, and cadences that keep governance and the access lifecycle running without manual work. Much of governance happens between requests. Usage is written in batches, invariants are checked for new violations, due review campaigns close, package rules pick up directory changes, and expired accounts are disabled. Nothing does these things unless a scheduler runs them, and a missing job fails quietly: access that should have ended keeps working in reports, and alerts never fire. This page lists each job, what it does, and how often to run it. [Background jobs](/docs/operations/jobs) covers how to run a worker in each deployment model. ## Governance jobs [#governance-jobs] | Job | Call | CLI | Suggested cadence | | -------------------- | -------------------------------------- | ------------------------ | ----------------- | | Write buffered usage | automatic (every minute) and on demand | none | none | | Invariant monitor | `iam.checkInvariants()` | `monitor-invariants` | hourly | | Role-mining snapshot | `roleMining.suggest` as a token holder | `mine-roles --tenant ID` | weekly | | Guardrail gate in CI | `invariants.run` | `check-invariants` | every deploy | * **Write buffered usage.** With the `accessUsage` option on, the server counts which actions people use and writes the counts every minute (`flushIntervalMs`), or sooner when `maxBuffered` pairs wait. No schedule is needed, but call `iam.flushAccessUsage()` on shutdown so the last minute is not lost. See [usage and role mining](/docs/guides/governance/usage-and-mining#access-usage). * **Invariant monitor.** Evaluates every organization's access invariants and records `invariant:broken` or `invariant:restored` once per change, so a webhook on `invariant:*` alerts without repeating itself. It catches what enforcement cannot, such as changes made by SCIM or other jobs. See [change safety](/docs/guides/governance/change-safety#monitor-and-alert). * **Role-mining snapshot.** Prints role-mining suggestions and peer outliers as JSON, so you can review them and see whether the access model gets simpler over time. * **Guardrail gate.** `check-invariants --fail-on-broken` runs every invariant and exits with `INVARIANTS_BROKEN` when one is broken or cannot be evaluated. Run it after `config-apply` so a deploy that crosses a line fails. ## Lifecycle jobs [#lifecycle-jobs] The jobs behind [privileged access](/docs/guides/privileged-access) and [certifications](/docs/guides/governance/certifications): | Job | Call | CLI | Suggested cadence | | -------------------------- | ---------------------------------- | --------------------------------- | ----------------------------- | | Expire and purge | `iam.purgeDeleted()` | `purge` | nightly or more often | | Package rules (birthright) | `iam.reconcilePackages()` | `reconcile` | every 15 minutes, after purge | | Close due campaigns | `iam.closeOverdueCertifications()` | `close-certifications` | daily | | Owner digest | `iam.sendAccessDigest()` | `digest` | daily | | Expiry reminders | `iam.sendExpiryReminders()` | `remind` | daily | | Access report to a channel | `reports.access` as a token holder | `report --tenant ID` | nightly | | Configuration drift check | `config.plan` as a token holder | `config-plan --fail-on-drift` | nightly and in CI | | Deliver email and webhooks | `iam.auth.dispatchOutbox()` | `outbox` | every minute | | Event subscribers | `iam.events.dispatch()` | none (in the subscribing process) | every minute | What each one does: * **Expire and purge** disables identities past their `expiresAt` (recording `identity:expire`). It deletes expired bindings, lapsed memberships, ended activations, and package assignments past their end, and marks stale access and package requests expired. It also removes organizations deleted more than 30 days ago (`--retention-days`). Expired access is refused at its next use regardless; this job decides how quickly statuses and reports catch up. See [access lifecycle](/docs/guides/privileged-access/lifecycle). * **Package rules** applies every [package rule](/docs/guides/privileged-access/automatic-assignment): it assigns packages to identities that newly match and removes them from holders who stopped matching. It must be scheduled, because SCIM provisioning, invitations, federated sign-in attributes, group membership changes, and expiry reach the rules only through it. `--fail-on-attention` exits non-zero (`RECONCILE_ATTENTION`) when a rule needs a person. * **Close due campaigns** applies every certification campaign created with `autoClose` once its due date has passed, removing the bindings reviewers revoked. * **Owner digest** emails each organization's owners its [access report](/docs/guides/privileged-access/access-report) when there is something to report, at most once per 20 hours. * **Expiry reminders** emails each person whose account, bindings, memberships, or packages end within a week, so they can ask for an extension in time. * **Access report to a channel** prints the report as JSON, for a chat channel or ticket. * **Configuration drift check** exits non-zero (`CONFIG_DRIFT`) when a tenant no longer matches its reviewed [configuration file](/docs/guides/privileged-access/config-as-code), so hand edits are noticed. * **Deliver email and webhooks** sends the messages that operations and the other jobs queued in the delivery outbox, retrying failures with backoff. Run it after `digest` and `remind`, which only queue their emails. * **Event subscribers** runs in-process subscribers, plugin hooks, and `events.onEvent` for newly recorded events. It belongs in the process that registers the subscribers. The CLI `outbox` command also runs this dispatch, but only for the plugins and `events.onEvent` in its configuration file. See [events](/docs/guides/events). Two storage jobs belong in the same schedule: `sweep` (`iam.sweepExpired()`) deletes expired sessions, remembered devices, relationship tuples, and old deliveries, and `audit-archive` (`iam.archiveAudit()`) copies new audit events to your archive. See [background jobs](/docs/operations/jobs) and the [audit chain](/docs/guides/events/audit-chain). ## Credentials [#credentials] Two kinds of jobs appear above: * **Deployment operations** (`purge`, `sweep`, `reconcile`, `close-certifications`, `digest`, `remind`, `monitor-invariants`, `outbox`, `audit-archive`) need no credential, only the configuration file. They act as `deployment-operator`, and the ones that change access record their own audit events. * **Token jobs** (`report`, `mine-roles`, `check-invariants`, `config-plan`, `config-apply`) act as the session or API key in `BETTER_IAM_TOKEN`, so each run is authorized and audited like the console. Give them a [scoped API key](/docs/guides/privileged-access/lifecycle#api-key-hygiene) of a service account with only the permissions they need (`iam:analysis:read`, `iam:invariants:read`, `iam:config:read`, `iam:identities:read`, and so on). ## Example crontab [#example-crontab] ```sh title="crontab" CONFIG=/etc/better-iam/better-iam.config.mjs # Every minute: deliver email and webhooks * * * * * better-iam outbox --config $CONFIG # Every 15 minutes: package rules, after purge */15 * * * * better-iam reconcile --config $CONFIG --fail-on-attention # Hourly: invariant monitor 0 * * * * better-iam monitor-invariants --config $CONFIG # Nightly: expire and purge, sweep expired records, close due campaigns 0 2 * * * better-iam purge --config $CONFIG 15 2 * * * better-iam sweep --config $CONFIG 30 2 * * * better-iam close-certifications --config $CONFIG # Mornings: email owners and people, then deliver 0 7 * * * better-iam digest --config $CONFIG && better-iam remind --config $CONFIG && better-iam outbox --config $CONFIG # Weekly: role-mining snapshot (as a token holder) 0 6 * * 1 BETTER_IAM_TOKEN=... better-iam mine-roles --config $CONFIG --tenant TENANT_ID ``` To catch a job that stopped running, use `better-iam doctor`. Among other things, it reports: * expired bindings or memberships older than a day (`purge` is not running); * records due for `sweep` for more than two days; * outbox messages, and audit hooks, waiting more than 15 minutes; * audit events older than a day that are not archived, when `auditArchive` is configured. `--strict` makes it exit non-zero, which suits a health check. See [observability](/docs/operations/observability). - [Background jobs](/docs/operations/jobs): Running workers on servers, serverless platforms, and in-process timers. - [CLI reference](/docs/reference/cli): Every command with its flags and defaults. # Usage and role mining (/docs/guides/governance/usage-and-mining) > Record which actions people actually use, right-size roles, and mine the directory for redundant grants, bundles, and peer outliers. *Least privilege* means everyone holds only the access they need. It is easy to agree with and hard to practice, because nobody knows which grants are still needed, and access granted one person at a time turns into a tangle nobody can review. Two features help. *Access usage* records which actions each person actually uses, so you can find roles nobody needs and actions no holder touches. *Role mining* reads who holds what today and proposes simpler ways to grant the same access. Reading either needs `iam:analysis:read`; applying a role-mining suggestion needs `iam:analysis:update`. The console's Role mining page shows both, with Apply, Remove, and Create package buttons. ## Access usage [#access-usage] To know whether a grant is needed, you need to see it being used. Access usage counts every allowed check per person and action. It is off by default because it writes to storage; turn it on once and let it run for a review period, such as the 90 days the reports look back by default, before drawing conclusions. ```ts title="iam.ts" const iam = betterIam({ // ... accessUsage: true, // or { flushIntervalMs, maxBuffered } }); // On shutdown, write what is still buffered. await iam.flushAccessUsage(); ``` ### What is counted [#what-is-counted] Every allowed `authorize` or `authorizeMany` check and every allowed provisioning operation counts. Root overrides and calls made during impersonation do not, because they are not the person's own use of their grants. Uses are counted in memory per person and action and written in batches, so the request path never waits on storage. The `accessUsage` collection holds one record per identity and action with `firstUsedAt`, `lastUsedAt`, and `count`. `accessUsageTracking` remembers when each tenant's recording began, so reports can tell whether the evidence covers a whole window. `iam.flushAccessUsage()` writes whatever is still buffered and returns `{ written }`. Call it in your shutdown hook, so a deploy does not lose the last minute of usage. ### Read usage [#read-usage] `roleMining.usage({ tenantId, identityId?, limit?, offset? })` lists the raw records, most recently used first, 100 per page by default and at most 1000. It also returns `tracking` (whether the option is on), `trackingSince` (when recording began), and the `total` number of records. Use it to answer "what does this person actually do?" on a member's detail page. ```ts const { records } = await iam.api.roleMining.usage(credential, { tenantId, identityId: alice.id }); // records: [{ identityId, action, firstUsedAt, lastUsedAt, count }] ``` ### Right-size roles [#right-size-roles] Raw usage is too detailed to act on. `roleMining.rightSize({ tenantId, unusedDays? })` turns it into recommendations. It compares every live binding with its holders' use in the last `unusedDays` (90 by default). It then reports who holds a role they do not use, and which of a role's actions nobody uses at all. Run it after a full window: ```ts const result = await iam.api.roleMining.rightSize(credential, { tenantId, unusedDays: 90 }); if (!result.complete) console.warn('Usage does not cover the whole window yet'); for (const entry of result.entries) console.log(entry.identity.name, entry.role.name, entry.status, entry.unusedActions); for (const role of result.roles) console.log(role.role.name, 'never used:', role.neverUsed); ``` * A role's actions are the known actions its allow statements (own, attached, and inherited) match. * Each `entries` item is a person whose use of a role is `unused` (none of its actions) or `partial` (some never used), with the used and unused actions and whether the role reaches them directly or through a group (`via`). * `roles` lists, per role, the actions no holder used: the candidates for a narrower role. * `complete` turns true once usage has been recorded for the whole window. Before that, "unused" only means "not since tracking began". Both read methods write buffered usage first, so results include the latest checks. The console's Role mining page shows a Least privilege card with a Remove button for unused direct bindings. Usage also powers [review recommendations](/docs/guides/governance/certifications#recommendations) for certification campaigns, the periodic reviews where reviewers keep or revoke each binding. ## Role mining [#role-mining] Over time the same access gets granted in several ways: a role bound to a person directly and again through their group, the same role bound to every member of a team one by one, two roles with identical permissions. Each is harmless alone, but together they make access hard to review and easy to leave behind when people move. *Role mining* finds these patterns. `roleMining.suggest({ tenantId, minIdentities?, minRoles?, kinds?, limit? })` reads who holds what and returns `summary` counts per kind and a list of `suggestions`, most actionable first. Each suggestion has: * a deterministic `id` (the same condition always gets the same ID); * the roles and people involved; * `savings`, the number of grants the change would save; * `applicable`, whether `roleMining.apply` can carry it out for you. `minIdentities` (default 3) is how many people must share a pattern, and `minRoles` (default 2) the smallest role combination reported as a bundle. `kinds` limits the result to some kinds and `limit` caps its length. | Kind | What it finds | Carried out by | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | | `redundant-binding` | Direct bindings of a role the person also receives through a permanent group membership, from a standing binding under the same authority that is at least as broad (no window, no later start, no earlier end). Removing them changes nothing today. | `roleMining.apply` | | `group-binding` | A role every permanent member of a group (at least `minIdentities`, default 3) holds through a plain direct binding (standing, permanent, not from a package). Bind it to the group once so joiners get it and leavers lose it. | `roleMining.apply` | | `duplicate-roles` | Roles whose inline, attached, and inherited statements are identical. | Role edits | | `bundle` | Role combinations of at least `minRoles` (default 2) roles held together by at least `minIdentities` people, skipped when a package or an inheriting role already has exactly those roles. | `packages.create` | Bundles are found by looking for the largest sets of standing roles that the same group of people hold together (closed itemsets, in data-mining terms). Grant them as one access package ([access packages](/docs/guides/privileged-access/access-packages)), optionally assigned [automatically by attribute](/docs/guides/privileged-access/automatic-assignment), or as an inheriting role. ### Apply a suggestion [#apply-a-suggestion] Two kinds of suggestion can be carried out for you, so a cleanup is one click (or one call) instead of dozens of binding edits: ```ts const { suggestions } = await iam.api.roleMining.suggest(credential, { tenantId }); const simplest = suggestions.find((item) => item.kind === 'group-binding' && item.applicable); if (simplest) await iam.api.roleMining.apply(credential, { tenantId, suggestionId: simplest.id }); // { applied, createdBindingId, removedBindingIds } ``` `roleMining.apply({ tenantId, suggestionId })` (`iam:analysis:update`) carries out a `group-binding` or `redundant-binding` suggestion in one transaction. It recomputes the suggestion first, so pass the same `minIdentities` and `minRoles` you gave `suggest`. It fails with `NOT_FOUND` once the suggestion no longer holds, and with `INVALID_INPUT` for a suggestion that is not `applicable`. It runs under the authority the original bindings used and with the caller's own binding rights: * The group binding is created under the authority the direct bindings share, so the caller needs that authority (or root), `iam:bindings:create` on the role, and `iam:bindings:delete` on every removed binding. * Suggestions whose direct bindings come from different authorities are listed with `applicable: false`. * Bundles and duplicate roles are left to `packages.create` and role edits. * Enforced invariants ([change safety](/docs/guides/governance/change-safety#access-invariants)) guard `roleMining.apply` like any other access change. ### Peer outliers [#peer-outliers] When someone moves from sales to finance, they often keep their sales access; when someone joins a team, they often lack something everyone else has. Comparing a person with their colleagues catches both. `roleMining.outliers({ tenantId, peerBy?, threshold?, commonShare?, minPeers? })` compares each active person with their peers: people with the same manager (`peerBy: 'manager'`, the default) or the same value of a declared identity attribute (`'attribute:department'`). ```ts const { outliers } = await iam.api.roleMining.outliers(credential, { tenantId, peerBy: 'attribute:department', }); // outliers: [{ identity, peerValue, peers, unusualRoles, missingRoles }] ``` * A role is **unusual** when fewer than `threshold` (0.25) of the peers hold it: access that outlived a move. * A role is **missing** when at least `commonShare` (0.8) of the peers hold it and the person does not: what a joiner still needs. * Peer groups with fewer than `minPeers` (3) others are skipped, and just-in-time bindings count as held. ## From the command line [#from-the-command-line] `better-iam mine-roles --tenant ID [--peer-by KEY]` prints both reports, the suggestions and the peer outliers, as JSON. It acts as the session or API key in `BETTER_IAM_TOKEN`, which needs `iam:analysis:read`. Run it weekly and keep the output as a snapshot, so you can see whether the access model is getting simpler over time. ```sh BETTER_IAM_TOKEN=... better-iam mine-roles --config better-iam.config.mjs --tenant TENANT_ID --peer-by attribute:department ``` - [Certifications](/docs/guides/governance/certifications): Put the usage evidence in front of reviewers. - [roleMining API reference](/docs/reference/api/role-mining): Signatures and result types. - [Access reviews](/docs/guides/authorization/reviews): Simulation, who-can queries, and the access analysis findings. # Data and consistency (/docs/guides/concepts/data-and-consistency) > How Better IAM stores its records, why every write is serialized, how side effects leave through the outbox, and how plugins and protocols compose. Better IAM keeps all of its state in your database, in a single table behind the `IamStore` contract. It trades some write throughput for a simple guarantee: every authorization decision and every revocation sees the current state, and every change is atomic with its record in the audit chain. This page explains the storage model, the transaction rules that make that guarantee hold, and how side effects, plugins, and protocols fit around it. ## The storage model [#the-storage-model] Better IAM keeps all of its records in one generic table, so it can share a database with your product without adding dozens of tables of its own. The SQL storage adapters (SQLite, libSQL, and PostgreSQL) share that schema. Records live in a versioned `iam_records` table: | Column | Holds | | ------------ | -------------------------------------------------------------- | | `collection` | The record kind, such as `identities` or `sessions`. | | `id` | The record ID. The primary key is `(collection, id)`. | | `tenant_id` | The owning tenant. Tenant ownership of a record is immutable. | | `unique_key` | An optional natural key, unique per `(collection, tenant_id)`. | | `data` | The record as JSON. | Two small tables track the schema: `iam_schema_version` and `iam_migrations`, which records named schema steps. `iam.initialize()` applies pending migrations and chains any audit events recorded before the hash chain existed. Run it, or the CLI's `migrate` command, once per deployment before serving traffic. Composite database constraints enforce scoped uniqueness, such as one email per tenant. Where uniqueness must be global, the record ID carries the natural key: the `tenantAliases` collection uses the alias as its record ID, so the primary key makes aliases globally unique, and `domainOwners` is keyed by the verified domain. Services enforce cross-record references (a binding's role, a membership's group) under transaction isolation. Beyond identities and tenants, collections hold every kind of IAM state: | Area | Collections | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | Tenancy | `tenants`, `tenantAliases`, `tenantDomains`, `domainOwners`, `ownerInvitations`, `memberInvitations` | | Accounts and sessions | `identities`, `sessions`, `externalIdentities`, `identityLinks` | | Authentication | `authChallenges`, `authMfa`, `authPasskeys`, `authDevices`, `authSignIns`, `authBlocks`, `authRateLimits`, `passwordHistory` | | Access model | `policies`, `policyVersions`, `roles`, `bindings`, `groups`, `groupMembers`, `grantAuthorities`, `principalBoundaries`, `trusts` | | Catalog | `actions`, `resourceTypes`, `resources`, `relationships` | | Delivery and audit | `outbox`, `webhooks`, `auditHooks`, `audit`, `auditChains` (chain heads, keyed by tenant ID) | | Protocols | OAuth, SAML, SCIM, and Shared Signals artifacts | Product data stays in your product's own database. Better IAM does not make a write to a separate product database atomic with an authorization check. ## Serialized transactions [#serialized-transactions] Access control is full of check-then-write decisions. Picture two administrators each removing a different owner of the same tenant at the same moment. Each request checks "is there another owner left?", sees yes, and proceeds, and the tenant ends up with no owner at all. Better IAM prevents this class of bug by running such transactions one at a time. Transactions serialize writes and the reads that decide them: * **SQLite** takes an immediate writer lock (`BEGIN IMMEDIATE`) before any application read. * **PostgreSQL** takes a transaction-scoped advisory lock, shared by every adapter instance on the database. * **libSQL** uses `BEGIN IMMEDIATE` for local files; remote servers (Turso, `sqld`) queue write transactions themselves. Serialization is what makes check-and-write operations safe: consuming a single-use token, protecting the last owner, enforcing a plan limit, and re-validating a principal all read and write inside one serialized transaction. All writes go through `transaction()`, and nested transactions join the outer one and roll back with it. > **Always go through the adapter.** Every instance that touches these tables must use the adapter contract. Raw SQL writes can violate service-level integrity and revocation guarantees, for example by leaving a session alive after its identity was disabled. This design favors consistent authorization and revocation over write throughput. Large installations should measure transaction latency and record-scan costs; see [storage](/docs/operations/storage) for query pushdown, indexes, and diagnostics. ## No permission cache [#no-permission-cache] Better IAM keeps no cross-request permission cache. Token validity and the current role and policy state are checked on use, inside the transaction of the request that uses them. Disabling an identity, revoking a binding, ending a session, or tightening a tenant policy therefore takes effect on the very next request, in every process. Two small, bounded exceptions trade exactness for write volume: * A session's `lastSeenAt` is touched at most once a minute, or once per tenth of the idle timeout when that is shorter. Idle expiry is accurate to that interval and only ever earlier than configured, never later. * Each process reuses a tenant's list of [network blocks](/docs/guides/authentication/tenant-policy#network-blocks) for five seconds, so a new block reaches other processes within that time. ## Side effects leave through the outbox [#side-effects-leave-through-the-outbox] Sending an email inside a transaction is a trap: if the transaction then rolls back, the person received a reset link for a change that never happened, and if the mail server is slow, every other write waits. So external side effects never run inside a write transaction. Emails, SMS messages, and webhook deliveries are written to the outbox collection in the same transaction as the change that caused them, with payloads sealed by authenticated encryption. A worker delivers them afterwards: nothing is sent until something calls the dispatch functions below. * **At least once.** Delivery is at least once, so callbacks must deduplicate by message ID. * **Retries.** A failed attempt is retried with exponential backoff from thirty seconds to one hour and abandoned after `authentication.maxDeliveryAttempts` (25 by default), with `failedAt` and `lastError` recorded. * **Events.** Subscribers, plugin `afterAudit` hooks, and webhook deliveries are queued inside the recording transaction and dispatched after it commits, so nothing is emitted for a change that rolled back and nothing committed is lost. See [events](/docs/guides/events). Two calls drain the queues. Run both from your worker schedule, every few seconds or minutes depending on how quickly people should receive their email: * `iam.auth.dispatchOutbox()` delivers queued emails, SMS messages, and webhook deliveries through your callbacks and the webhook transport, and returns `{ delivered, failed, abandoned }`. * `iam.events.dispatch()` runs plugin `afterAudit` hooks, the `events.onEvent` callback, and in-process subscribers for events that have committed. The `outbox` CLI command runs both once, for a cron schedule. In-process subscribers exist only in your application's process, so that process must call `iam.events.dispatch()` itself; the CLI serves plugin hooks and `events.onEvent` only. See [scheduled jobs](/docs/operations/jobs). ## Retention [#retention] Deleted data has to disappear eventually, but an audit record that points at a vanished identity is useless. So deletion is two-phase: * Deleting a tenant tombstones its subtree and stamps `deletedAt`. The data stays for a retention window, during which the deletion can still be investigated. * Deleting an identity leaves a tombstone without email or secrets, so audit records still name who acted. Two jobs do the cleanup. Schedule both from your worker: * `iam.purgeDeleted({ retentionMs })` (also the `purge` CLI command) removes every tenant-scoped record of tenants deleted longer ago than `retentionMs` (30 days by default), calling plugin `purge` callbacks in the same transaction; audit records are preserved. It also deletes expired temporary bindings and memberships, marks stale access requests expired, disables identities past their `expiresAt`, and removes expired challenges, rate-limit windows, and lapsed blocks. * `iam.sweepExpired()` deletes expired sessions, protocol artifacts, and old delivery records in short batches, so those collections do not grow without bound. Expired credentials and access are refused at their next use even before these jobs run, so the schedule only affects how quickly storage and reports catch up. ## Plugins [#plugins] Sometimes your product needs a feature of its own that should be governed like IAM data: a record type with its own actions, HTTP endpoints, and audit trail, or a hook that must run inside every change. A plugin adds that without forking Better IAM, and inherits the same guarantees because it runs inside the same pipeline. A plugin is a plain object with an `id`, passed in the `plugins` option. It extends the same catalog, transaction, and audit pipeline that built-in operations use: | Member | Purpose | | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `actions`, `resourceTypes` | Catalog entries, validated like the product's own. A name declared twice is rejected at construction. | | `endpoints` | HTTP endpoints (`POST {basePath}/plugins/{id}/{path}`) that run inside a transaction with the authorized principal and may queue deliveries through `deliver`. | | `hooks.beforeOperation`, `hooks.afterOperation` | Run inside every `operation` transaction, before the mutation and after it (before the audit record). Throwing aborts the operation. | | `resolveContext` | Adds trusted, server-derived keys to the policy evaluation context. | | `afterAudit` | Runs for committed events, from the dispatcher, at least once. | | `migrate`, `purge` | Plugin migrations, and removal of plugin-owned records when a tenant is purged. | `@better-iam/projects` is the reference implementation. See [extensions](/docs/operations/extensions) for the adapter and plugin contracts. ## Federation composition [#federation-composition] Enterprise customers bring their own identity providers and directories, but not every deployment needs every protocol. So the protocol packages (OAuth/OIDC, SAML, SCIM, Shared Signals) are separate from the server and are loaded only when you import them. Each protocol service takes the `iam.protocolHost` callbacks, which let it sign people in and provision them through the same pipeline as everything else, plus its own explicit configuration. `iam.useProtocol(service)` then mounts the service on the handler under its base path. `protocolHost` is a trusted server capability, never an HTTP endpoint. * Standard protocol handlers use `Request`/`Response` and run through `iam.handler`. * The OAuth issuer uses Node's request/response interface and must run through `iam.nodeHandler`. * Protocol mounts that issue sessions run with the request's client details, so network allowlists, blocks, and IP binding judge those sessions like any other sign-in. Federation maps `(tenant, provider, issuer, subject)` to an identity. Unmapped verified email claims may provision a new account, but an existing email collision returns `ACCOUNT_LINK_REQUIRED`: the application must complete an explicit, verified linking flow. No email-only association is performed. See [federation](/docs/federation) and [protocol mounts](/docs/operations/deployment/protocol-mounts). # Architecture overview (/docs/guides/concepts) > How Better IAM is put together, from the packages and the surfaces of betterIam() to the pipeline every operation runs through. Better IAM is the identity layer of your application: it knows who is signing in, which organization they belong to, and what they may do. Instead of calling a hosted service, you run it as a set of packages inside your own process, against your own database. This page explains how those packages fit together and what happens to every request, so the rest of the documentation has a map to hang on. ## Key terms [#key-terms] These words appear on almost every page. Throughout the documentation, a dotted underline marks a term; select it for a short definition. The [glossary](/docs/reference/glossary) lists every term. | Term | Meaning | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Tenant** | An isolated account, such as a customer organization or one of its projects. Tenants form a tree, and each has its own people, roles, and policies. See [tenants and identities](/docs/guides/concepts/tenants-and-identities). | | **Identity** | A person (`kind: 'user'`) or a machine (`kind: 'service'`) in one tenant. | | **Session** | The stored record behind a credential: a signed-in user session, an API key, or an assumed-role session. | | **Principal** | The caller an authorization decision is about: an identity together with the session it presented. Policies read its properties as `principal.*`, such as `principal.mfa`. | | **Action** | A named operation, such as `documents:read` or the built-in `iam:identities:create`. | | **Resource** | The thing an action is performed on, named `type/id`, such as `document/42`. | | **Policy** | A versioned JSON document of allow and deny statements over actions and resources, with optional conditions. | | **Role** | A named set of permissions or policies that can be handed to people. | | **Binding** | The grant of a role to an identity or a group in a tenant. Bindings can be temporary, or eligible for just-in-time activation. | | **Boundary** | A policy that caps what a tenant or an identity can ever be granted. Boundaries constrain; they never grant access. | | **Grant authority** | The delegation record under which an administrator hands out roles. It carries a ceiling (the most it may grant) and a chain back to whoever delegated it; revoking it disables what was granted under it. | ## How the packages fit together [#how-the-packages-fit-together] Better IAM is a set of layered packages. Each layer depends only on the contracts below it, so you can read, test, and replace them one at a time. * The **pure core** (`@better-iam/core`) defines policies, shared types, and the `IamStore` storage contract. * **SQL adapters** implement `IamStore` for SQLite, libSQL, and PostgreSQL. * The **authentication** and **protocol** packages (OAuth/OIDC, SAML, SCIM) operate on that contract. * The **server** (`@better-iam/server`) composes authentication, authorization, provisioning, and transport into one object: `betterIam(options)`. * The **client** (`@better-iam/client`) contains only a fetch-based transport and type inference, so it is safe to ship to browsers. * The **umbrella** package `better-iam` re-exports everything through subpath imports without eagerly importing the protocol implementations. A protocol is loaded only when you import its subpath. ## Public surfaces [#public-surfaces] You create one Better IAM instance with `betterIam(options)` and import it wherever your server code needs identity or authorization. The object it returns is the whole public surface. Most applications use a handful of its members: | Member | What it is for | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `api` | Every operation, grouped: `api.auth` for end-user authentication, plus the provisioning groups. | | `authorize`, `authorizeMany`, `listAccessible` | Authorization queries: one decision, up to 50 decisions for one tenant, and a reverse query over managed resources. | | `require` | `authorize` that throws `ACCESS_DENIED`; call it right before a protected operation. | | `handler`, `nodeHandler` | The Fetch (`Request`/`Response`) and Node.js HTTP transports for the whole API. | | `events` | In-process subscriptions to audit events: `events.subscribe(patterns, handler)` registers your code for committed events whose action matches, such as `iam:identities:*`, and returns an unsubscribe function. `events.dispatch()` delivers the queued events to it; run it on a timer in the same process. | | `initialize` | Runs migrations and backfills the audit chain; normally run through the CLI. | `api` is organized in groups, one per kind of record. `api.auth` holds what end users do for themselves (sign in, manage factors and sessions). The other groups are **provisioning** operations that administrators and your server perform on a tenant: | Group | What it is for | | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `tenants` | Create organizations, rename, move, suspend, and delete them, and set their authentication policy and plan limits. | | `identities` | Create, invite, update, offboard, and delete people; export their data; end their sessions. | | `serviceAccounts`, `credentials` | Machine identities and the API keys they authenticate with. | | `groups` | Sets of identities that receive roles together. | | `roles`, `policies`, `bindings`, `authorities` | The access model: what roles allow, who holds them, and who may hand them out. | | `actions`, `resourceTypes`, `resources`, `relationships` | The resource catalog: the vocabulary of actions and resource types, managed resources, and sharing. | | `trust` | Platform-controlled permission for an identity to assume a role in another tenant. | | `links` | Explicit links between one person's identities in different tenants, for account switching. | | `root` | Grant and list platform root administrators. | | `accessRequests` | Time-boxed requests for access that a reviewer approves. | | `webhooks`, `audit` | Signed event deliveries and the tamper-evident audit log. | | `assertions` | Short-lived signed tokens that tell your other services who is calling. | Governance groups (certifications, separation of duties, role mining, and more) and federation groups build on these. The [API reference](/docs/reference/api) lists every group and method with its HTTP route and TypeScript signature. ### One operation, three ways to call it [#one-operation-three-ways-to-call-it] Every group method takes `(credential, input)` on the server. The browser client and HTTP call the same operation, with the request itself as the credential. **Server:** ```ts title="app/actions.ts" import { iam } from './iam'; // The credential is the incoming request's headers (a cookie or bearer token), or { token }. const credential = { headers: request.headers }; await iam.api.identities.invite(credential, { tenantId, email: 'alice@example.com' }); ``` **Browser client:** ```ts title="app/invite.ts" import { createIamClient } from 'better-iam/client'; import type { iam } from './iam'; const client = createIamClient({ baseURL: 'https://identity.example.com' }); await client.identities.invite({ tenantId, email: 'alice@example.com' }); // the session cookie is the credential ``` **HTTP:** ```bash curl -X POST https://identity.example.com/api/iam/identities/invite \ -H "Authorization: Bearer $BETTER_IAM_TOKEN" \ -H "Content-Type: application/json" \ -H "X-Better-IAM: 1" \ -d '{ "tenantId": "3f2c9a4e-…", "email": "alice@example.com" }' ``` The credential says **who is calling**. It contains request headers (with a session cookie or a bearer token) or an opaque token, never caller-supplied identity claims such as a user ID. The server looks the credential up and resolves it to the current identity and session records itself, so a caller cannot claim to be someone else. A few operations happen before anyone holds a credential, so they are public: * `tenants.lookup` resolves an organization's sign-in alias, such as `acme`, for a login page. * `domains.discover` finds the organization that owns an email domain, for "sign in with your work email". * `tenants.acceptInvitation` and `identities.acceptInvitation` redeem an invitation and create the invitee's account. * The sign-in ceremonies in `api.auth`, such as `signIn` and `verifyMfa`, are public for the same reason. * `sts.assumeRoleWithWebIdentity` exchanges a token from an external OpenID Connect provider for a role session; that external token is its credential. ### Deployment-only capabilities [#deployment-only-capabilities] Some members exist for trusted server code and deployment tooling only: | Member | What it is for | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `iam.auth` | Low-level authentication primitives for trusted integrations, such as `withClient` (record client details on sessions you create) and `dispatchOutbox` (deliver queued emails, SMS messages, and webhooks). | | `iam.store` | The raw storage adapter, for migrations, snapshots, and diagnostics. | | `iam.bootstrap`, `iam.recoverRoot` | Create the root tenant and first root administrator, or a replacement administrator when everyone is locked out. | | `iam.assertionKey` | The derived key your other services use to verify assertions. | | `iam.protocolHost` | The trusted callbacks that the OAuth, SAML, and SCIM packages use to sign people in and provision them. | | Jobs | Scheduled maintenance such as `purgeDeleted` (retention), `sweepExpired` (expired sessions and artifacts), and `rotateSecrets` (re-seal data after a secret rotation). See [scheduled jobs](/docs/operations/jobs). | > **Never expose these through RPC.** The HTTP router explicitly excludes root bootstrap, recovery, raw storage, cryptographic helpers, and session-issuance primitives. Do not re-expose `iam.auth`, `iam.store`, `bootstrap`, `recoverRoot`, `assertionKey`, or `protocolHost` through application RPC reflection. ## Module layout [#module-layout] You do not need the module layout to use Better IAM, but it helps when you read the source, debug a decision, or write a plugin. The server package is a set of small modules that `betterIam()` composes in `src/index.ts`: | Module | Responsibility | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `options.ts` | Option types and `resolveConfig`, which validates and defaults configuration once. | | `catalog.ts` | The permission catalog: built-in and declared actions, resource types, tenant-defined registrations, policy validation. | | `plugins.ts` | Plugin validation at construction. | | `context.ts` | The shared `ServerContext`: configuration, storage, auth, catalog, and record helpers (tenants, ancestry, authorities, scoping, owner setup). | | `events.ts` | Chained audit recording, in-transaction fan-out, webhook signing and transport, post-commit dispatch, subscriptions. | | `decisions.ts` | Grant paths, relationships, resource resolution, `prepareDecision`, `decide`, and simulated principals for reviews. | | `observe.ts` | Observability spans around operations, authorization queries, authentication calls, and HTTP requests. | | `assertions.ts` | Stateless HS256 assertions: key derivation, issuance (the `assertions` group), and `verifyAssertion`. | | `sync.ts` | Configuration as code: the `config` group (`export`, `plan`, `apply`) over the mutation helpers the `api/*` files export. | | `usage.ts` | Access usage tracking (the `accessUsage` option): an in-memory recorder behind `ctx.usage`, flushed in batches. | | `invariants.ts` | Access invariants: evaluation, the snapshot and verify pair `operation` runs around access-changing actions, and the `checkInvariants` job. | | `agreements.ts` | Terms of use: acceptance currency and the `principal.agreements` and `principal.pendingAgreements` context. | | `principals.ts` | Credential resolution for user sessions, API keys, and assumed roles; per-transaction re-validation. | | `operations.ts` | The transactional `operation` envelope, `authorize`, `authorizeMany`, `listAccessible`, plugin calls. | | `flows.ts` | Multi-step flows: invitation redemption, account linking and switching, role assumption. | | `api/*.ts` | One file per API group; `api/index.ts` composes them. | | `lifecycle.ts` | `initialize` (migrations and audit-chain backfill), `bootstrap`, `recoverRoot`, and the retention worker `purgeDeleted`. | | `retention.ts` | `sweepExpired`: deletes expired sessions, protocol artifacts, and old deliveries by walking the expiry indexes. | | `self-check.ts` | `selfCheck`: the configuration and storage findings behind `doctor` (schema, durability, secrets, transports, scheduled jobs). | | `secrets.ts` | `rotateSecrets`: re-seals values encrypted with `previousSecrets` using the current deployment secret. | | `federation.ts` | `protocolHost` callbacks for the OAuth, SAML, and SCIM packages. | | `http.ts` | Fetch and Node transports, the CSRF boundary, cookies, CORS, protocol mounts, and the auth route table. | Modules reach each other through the context at call time (`ctx.decisions.decide(...)`). That keeps them free of import cycles and lets you read each one on its own. The other packages follow the same idea: * **Authentication.** `service/base.ts` holds configuration and the session core. `sessions.ts`, `account.ts`, `passwordless.ts`, `mfa.ts`, and `passkeys.ts` each extend the previous class with one feature. `outbox.ts` and `rate-limit.ts` are plain modules. * **SCIM** separates its filter grammar, discovery documents, provisioning logic, and HTTP handler. * **OAuth** separates the provider adapter from the provider service. ## The request pipeline [#the-request-pipeline] Every operation runs through the same pipeline, whether it comes from a browser, a server action, the CLI, or a SCIM connector: resolve the credential, authorize, apply the change in one serialized transaction, and append an audit event. Direct calls and HTTP enter the same operation service. Having one path is what makes the guarantees hold everywhere. A permission check, a revocation, or an audit rule enforced in the pipeline cannot be skipped by calling the API a different way. ### Resolve the credential [#resolve-the-credential] The principal service turns the credential into an identity and a session. User sessions resolve through the authentication service; API keys and assumed-role sessions resolve in the server. When a call carries request headers, the client details (IP, user agent) are derived from them the same way the HTTP handler derives them, so network allowlists and session binding judge the presenting address. See [sessions](/docs/guides/authentication/sessions). ### Re-validate inside the transaction [#re-validate-inside-the-transaction] The credential was resolved a moment ago, but an administrator may have disabled the person or ended the session since. So the transaction re-reads the identity and session and re-checks every revocation condition before anything is used: * the identity is active and not past its `expiresAt`; * the session exists, is unexpired, and has not passed its idle timeout; * the tenant and all of its ancestors are active; * MFA requirements still hold, and an impersonation's source session is still live; * the recorded address is allowed and not blocked. API keys also need an unrevoked issuing authority; assumed roles need an intact trust, role, and source session. ### Authorize [#authorize] Every provisioning operation is itself an action on a resource. Inviting a person, for example, is the action `iam:identities:create` on `iam/{tenantId}`. It is authorized like any product action: against the principal's roles, policies, boundaries, and grant authorities (see [key terms](#key-terms)). A denial records a `deny` audit event and commits only that record; the caller receives `ACCESS_DENIED`. An impersonation session is allowed only what both the member and the impersonating administrator may do. ### Mutate [#mutate] Plugin `beforeOperation` hooks run, then the change itself. Afterwards the transaction checks [separation-of-duties](/docs/guides/authorization/separation-of-duties) rules (one person may not hold two conflicting roles) and [access invariants](/docs/guides/governance/change-safety) (guardrails that must always hold), then runs plugin `afterOperation` hooks. Any failure rolls the whole transaction back, so a change that breaks a rule never becomes visible. ### Audit and fan out [#audit-and-fan-out] One audit event is appended in the same transaction. Events form a hash chain per tenant (each includes the hash of the one before), so altering, reordering, or removing a record is detectable. Subscribers, plugin `afterAudit` hooks, and webhook deliveries are queued in that transaction and dispatched after it commits, so nothing is emitted for a change that rolled back. See [events](/docs/guides/events). No cross-request permission cache exists. Token validity and the current role and policy state are checked on every use, so a revocation takes effect on the next request. See [data and consistency](/docs/guides/concepts/data-and-consistency). > **Client decisions are advisory.** `authorizeMany` and `listAccessible` exist to render menus and lists. Enforce authorization on the server immediately before performing the protected operation, with resource ownership loaded from trusted storage: call `iam.require` (or `authorize`) there. ## Next steps [#next-steps] - [Tenants and identities](/docs/guides/concepts/tenants-and-identities): Tenant trees, identity directories, invitations, root administration, and account linking. - [Resources and catalog](/docs/guides/concepts/resources-and-catalog): Actions, resource types, managed resources, and relations. - [Data and consistency](/docs/guides/concepts/data-and-consistency): The storage model, serialized transactions, the outbox, plugins, and federation composition. - [Authentication](/docs/guides/authentication): Sign-in methods, MFA, passkeys, sessions, and tenant policies. # Resources and catalog (/docs/guides/concepts/resources-and-catalog) > The vocabulary policies speak: actions, resource types, application-owned and managed resources, relations, and tenant-defined types. Authorization in Better IAM always asks the same question: may this principal (the signed-in caller) perform this action on this resource? The resource catalog defines both halves: it lists the action names a policy may use and the resource types those actions apply to, with their attributes and relations. Declaring them up front means a typo in a policy is caught when the policy is saved, not discovered later when access silently fails. This page explains the concepts; the [catalog guide](/docs/guides/authorization/catalog) covers configuration and validation in depth. ## The permission catalog [#the-permission-catalog] The permission catalog is the set of action names a policy may use. It combines four sources: | Source | Example | Where it comes from | | ---------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------- | | Built-in actions | `iam:identities:create`, `iam:bindings:create`, `iam:audit:read` | The platform's own operations. | | Product actions | `documents:read`, `reports:export` | `permissions.actions` and each resource type's `actions`. | | Plugin actions | `projects:read` | A plugin's `actions` list. | | Tenant-defined actions | `invoice:approve` | Registered by a tenant when `permissions.mode` is `'tenant-defined'`. | Registering an action grants nothing; it only makes the name valid in policies. Policy documents are validated against the catalog when they are stored: an exact action name that does not exist is rejected with `INVALID_ACTION`. Wildcard patterns such as `documents:*` are stored as written. Products and plugins cannot declare names under the reserved `iam:` or `tenant/` prefixes, or names containing `*`, `?`, or whitespace. ## Resource types [#resource-types] A resource type names a kind of thing your product protects, such as documents or projects, and says which actions apply to it and which attributes policies may test. You declare types in `permissions.resourceTypes`: ```ts title="iam.ts" export const iam = betterIam({ // ... permissions: { resourceTypes: { document: { actions: ['documents:read', 'documents:write'], attributes: { ownerId: 'string', classification: 'string' }, relations: ['viewer', 'editor'], }, project: { managed: true, actions: ['projects:read', 'projects:manage'], attributes: { archived: 'boolean' }, }, task: { managed: true, parent: 'project', actions: ['tasks:read', 'tasks:write'] }, }, }, }); ``` Type names are lowercase identifiers (a letter, then letters, digits, or hyphens, up to 64 characters). A small set of names is reserved for the platform: `iam`, `role`, `oauth-client`, `scim`, `saml`, `ssf`, `tenant`, `identity`, and `session`. Declaring a reserved name, or the same name twice (including across plugins), fails at construction with `INVALID_CONFIG`. Resource patterns in policies match `type/id` within an already-resolved tenant, for example `document/*` or `folder/home-${principal.id}`. Once `resourceTypes` is configured, an exact type in a pattern must exist (`INVALID_RESOURCE_TYPE`). ## Application-owned and managed resources [#application-owned-and-managed-resources] Every resource type is either application-owned or managed. The difference is where Better IAM finds a resource's tenant, owner, and attributes at decision time. | | Application-owned | Managed | | --------------- | ------------------------------------------------------------- | -------------------------------------------------------------- | | Declared as | A type without `managed` | `managed: true`, and every tenant-defined type | | Records live in | Your product's database | IAM's `resources` collection | | Resolved by | Your `resolveResource` callback | The registry; no callback | | Registered with | Nothing; your product owns the data | The `resources` group (see below) | | Extra context | Whatever your resolver returns | `resource.ownerId`, `resource.parentId`, `resource.parentType` | | Reverse queries | Not enumerable; use `authorizeMany` with IDs you already have | `listAccessible` lists what a caller may act on | For application-owned types, `resolveResource` must load ownership and attributes from trusted storage: ```ts title="iam.ts" resolveResource: async ({ tenantId, type, id }) => { const document = await documents.find(id); // your database return { tenantId: document.tenantId, type, id, attributes: { ownerId: document.ownerId } }; }, ``` > **Resolve from trusted storage.** Never copy the request's tenant ID into a fetched record to satisfy the resolver. The tenant the resolver returns is what authorization checks the caller against. The resolver must return the same `type` and `id` it was asked about, and the record's tenant must be the tenant the check names; anything else fails with `RESOURCE_MISMATCH` (403). Without a `resolveResource` option, checks on application-owned types fail with `RESOURCE_RESOLVER_REQUIRED`. Choose managed resources when you would rather not write a resolver, or when you need reverse queries such as "which projects may this person open?". You then tell Better IAM about each resource with the `resources` group: * `resources.register` records one resource with its attributes, an optional owner (`ownerId`), and an optional parent (`parentId`), for example when a project is created in your product. * `resources.registerMany` records up to 100 in one transaction, for imports. A denied item rejects the batch. * `resources.update` changes a resource's attributes or owner, and `resources.delete` removes it, together with its relationship tuples, when the product deletes it. * `resources.get` reads one registration, and `resources.list` lists them by type, parent, or owner. ```ts await iam.api.resources.register(credential, { tenantId, type: 'project', id: project.id, ownerId: creatorIdentityId, attributes: { archived: false }, }); ``` Attribute values are validated against the declared schema, `ownerId` must be an identity of the same tenant, and a parent must be a registered resource of the declared parent type. Registering requires `iam:resources:create` on `iam/{type}/{id}`, so policies can scope who may register which resources. Managed registrations are trusted authorization inputs: the `iam:resources:*` permissions decide who may create and edit them. ## Relations and relationship tuples [#relations-and-relationship-tuples] Roles answer "what can editors do?", but sharing needs "who may edit **this** document?". Writing a policy per document does not scale. Relations express sharing and ownership without listing resource IDs in policies. A relationship tuple records that an identity or a group stands in a named relation to one resource: *alice is an `editor` of `document/plans`*, *the design group are `viewer`s of `folder/specs`*. Only relations the resource type declares are accepted. * `relationships.create` adds a tuple, optionally with an `expiresAt`, for example when someone shares a document. * `relationships.list` finds tuples by resource, relation, or subject, for a "shared with" panel. * `relationships.delete` removes one, when access is unshared. During evaluation, the principal's live relations on the resource (held directly or through a group) appear as `resource.relations`, and those on its registered parent as `resource.parentRelations`: ```ts { effect: 'allow', actions: ['documents:read'], resources: ['document/*'], conditions: { ArrayContains: { 'resource.relations': ['viewer', 'editor'] } }, } ``` A relation still held by someone cannot be dropped from its type. See [relationships](/docs/guides/authorization/relationships). ## Identity attributes [#identity-attributes] Policies often depend on facts about the person rather than the resource: their department, clearance level, or region. `permissions.identityAttributes` declares these as typed attributes. Administrators set them with `identities.update` (or `serviceAccounts.update`), federation and SCIM can fill them from a directory, and policies read them as `principal.{name}`: ```ts permissions: { identityAttributes: { department: 'string', clearance: 'number' }, }, ``` Attribute names cannot shadow the principal keys the server derives itself, such as `id`, `tenantId`, `mfa`, `mfaTime`, `kind`, `owner`, `rootAdmin`, `groups`, `roles`, `sessionId`, `sessionKind`, `authMethod`, `impersonated`, and `impersonatorId`, so an attribute can never impersonate a fact the server vouches for. ## Tenant-defined resource types [#tenant-defined-resource-types] Platforms whose customers model their own data, such as a workflow or low-code product, cannot declare every resource type in advance. With `permissions.mode: 'tenant-defined'`, each tenant can extend the catalog for itself. An organization owner registers a type, with its actions, attributes, and relations, using `resourceTypes.register`: ```ts await iam.api.resourceTypes.register(ownerCredential, { tenantId, name: 'invoice', actions: ['read', 'approve'], // registered as invoice:read and invoice:approve attributes: { amount: 'number' }, relations: ['approver'], }); ``` * Tenant-defined types are always managed, and exist only in the tenant that registered them. * Their actions are namespaced under the type as `{type}:{verb}`. The `actions` list on `resourceTypes.register` creates them, `actions.register` adds one later, and `actions.list` shows the whole catalog a tenant can use. * `resourceTypes.update` changes a type's description, attributes, or relations and adds verbs; `resourceTypes.delete` and `actions.unregister` remove what is no longer used. * Names cannot collide with reserved names, platform resource types, or the namespace of any platform action (`INVALID_RESOURCE_TYPE`, `INVALID_ACTION`), so tenant-created actions cannot redefine platform namespaces. * Deleting a type or action is refused while resources, relationships, child types, or policies still use it (`RESOURCE_IN_USE`). * In the default `'catalog'` mode, only developer-defined names are allowed and these calls fail with `CATALOG_LOCKED`. ## From request to decision [#from-request-to-decision] Whichever way a resource is resolved, its attributes, owner, parent, and relations become `resource.*` context keys, and the policy engine evaluates the principal's roles and policies against them. Continue with [policies](/docs/guides/authorization/policies) and [conditions](/docs/guides/authorization/conditions). # Tenants and identities (/docs/guides/concepts/tenants-and-identities) > Tenant trees, isolated identity directories, invitations, sign-in aliases, root administration, account linking, and what suspension and deletion do. A multi-tenant product has to keep each customer's people, permissions, and data apart, while still letting you, the platform operator, run everything. Better IAM models each customer as a **tenant**, the equivalent of an AWS account: many people sign in to it, each with their own identity, credentials, and role bindings. Tenants form a tree, and each one keeps its own directory of identities. This page explains how the tree is shaped, how people get into a tenant, who administers the platform, how one person can move between tenants, and what happens when tenants and identities are suspended or deleted. ## Tenant trees [#tenant-trees] A tree lets you mirror how your customers are organized. The platform sits at the top, each customer organization below it, and each organization's projects or workspaces below that. Every level is a full tenant with its own members and access model, so an organization can give a contractor access to one project without touching the others. Every installation has one root tenant, created by `bootstrap`. Below it, tenants nest by type. The default hierarchy is `root → organization → project`, with a maximum depth of eight. You can define your own types with the `hierarchy` option: ```ts title="iam.ts" export const iam = betterIam({ // ... hierarchy: { types: { root: { allowedChildren: ['organization'] }, organization: { allowedChildren: ['workspace', 'project'] }, workspace: { allowedChildren: ['project'] }, project: { allowedChildren: [] }, }, maxDepth: 8, // 1 to 100 }, }); ``` The hierarchy must define `root`, every allowed child must be a defined type, and nothing may list `root` as a child. Runtime ancestry traversal rejects cycles. A tenant's `status` is one of: | Status | Meaning | | ----------- | ----------------------------------------------------------------------------------------- | | `pending` | Created, waiting for its owner to accept the invitation. | | `active` | In use. Sign-in and authorization require the tenant **and every ancestor** to be active. | | `suspended` | Temporarily unavailable, together with its whole subtree. | | `deleted` | Tombstoned; purged after the retention window. | > **Parents do not grant access to children.** No parent membership implicitly grants access to a descendant tenant. People act in the tenant their identity belongs to; reaching another tenant takes platform-controlled role assumption or root authority. ### Creating an organization [#creating-an-organization] When a new customer signs up, or a customer adds a project, you create a child tenant. `tenants.create` creates it under a parent and invites its first owner. It requires `iam:tenants:create` on the parent, recent authentication, and a valid grant authority: the new owner's powers are delegated from it. The child type must be allowed under the parent's type (`INVALID_HIERARCHY`), and the tree must stay within `maxDepth` (`MAX_DEPTH`). ```ts const { tenant, invitationId } = await iam.api.tenants.create(adminCredential, { parentId: rootTenantId, type: 'organization', name: 'Acme', ownerEmail: 'owner@acme.example', slug: 'acme', // optional sign-in alias }); // tenant.status === 'pending' ``` The new tenant starts `pending`. Its owner enrolls through a single-use `owner-invitation` email sent through the encrypted outbox, so the creator never sees the invitation secret. Organization creation therefore needs an email delivery callback (`DELIVERY_REQUIRED` otherwise). The owner accepts with the public `tenants.acceptInvitation({ tenantId, token, name, password })`. That creates their identity with a verified email, sets up the protected Owner role, activates the tenant (audited as `tenant:activate`), and signs them in, subject to any MFA requirement. Invitations last `onboarding.invitationLifetimeMs` (24 hours by default). When an owner does not get around to accepting in time, or the invitation went to the wrong address: * `tenants.resendInvitation` sends a fresh link with a new lifetime; the earlier link stops working. * `tenants.listInvitations` shows the tenant's owner invitations and whether each was consumed or revoked. * `tenants.revokeInvitation` cancels an invitation so its link can no longer be used. `tenantDefaults` in the server options (`limits` and `authPolicy`, validated at construction) are stamped on every tenant `tenants.create` creates, so a SaaS plan applies from the first sign-in. ### Plan limits [#plan-limits] SaaS plans usually cap how much a customer can create. Root administrators set plan limits on a tenant with `tenants.setLimits` (audited as `tenant:limits`; `null` clears them): ```ts await iam.api.tenants.setLimits(rootCredential, { tenantId, limits: { identities: 25, serviceAccounts: 5, webhooks: 3 }, }); ``` The keys are `identities` (members), `serviceAccounts`, `groups`, `roles`, `policies`, `resources` (registered managed resources), and `webhooks`. Every creation path checks the limit inside its transaction and fails with `LIMIT_EXCEEDED`, including invitations accepted later, self-registration, federation, SCIM, and bulk creation. Deleted tombstones do not count. `tenants.usage` reports the tenant's current counts, active sessions, how many people have MFA, and its limits. Call it to render a plan page or to meter usage for billing. ## Finding a tenant at sign-in [#finding-a-tenant-at-sign-in] Every sign-in names a tenant, because the same email address can belong to separate identities in different tenants. People do not know tenant IDs, so two public lookups let a login screen find the tenant from something they do know. **Aliases.** A tenant may carry a globally unique `slug`: 1 to 63 lowercase letters, digits, or hyphens, starting and ending with a letter or digit. `tenants.lookup({ slug })` resolves an active tenant without a credential, like an AWS account alias. Suspended, pending, and deleted tenants, and tenants under an inactive ancestor, are not resolvable. A slug is claimed in the same transaction as the tenant (`tenants.create`, `bootstrap`) or changed with `tenants.setSlug` (recent authentication; `null` releases it). A taken slug fails with `SLUG_TAKEN`, and a purged tenant releases its slug. ```ts const { tenantId } = await client.tenants.lookup({ slug: 'acme' }); await client.auth.signIn({ tenantId, email, password }); ``` > **Aliases are public.** Tenant aliases are discovery data by design. Apply ingress rate limits to `tenants.lookup` and never encode secrets in an alias. **Sign-in addresses and regions.** An alias can also be an address. With the `hosts` option, Acme signs in at `acme.signin.example.com` (or a custom hostname it verified, such as `login.acme.com`), and every request there is pinned to Acme. With the `regions` option, each organization has a home region, and sign-in for it is sent to that region's deployment. See [sign-in addresses and regions](/docs/operations/deployment/hosts-and-regions). **Email domains.** An organization can claim a domain with `domains.add` (`iam:domains:create`), prove control with a DNS TXT record, and mark it verified with `domains.verify`. The public `domains.discover({ email })` then returns the owning tenant with its alias, accepted sign-in methods, and MFA requirement, which is how "use your work email" works. A verified domain belongs to exactly one tenant, and consumer mailbox providers cannot be claimed. See [enterprise onboarding](/docs/federation/enterprise-onboarding). ## Identity directories [#identity-directories] Each customer should control its own people: who is a member, how they sign in, and what they may do, without seeing or affecting anyone else's. So every identity belongs to exactly one tenant. An identity's normalized email is unique **within** its tenant; another tenant may hold an entirely separate identity with the same email. Credentials, external-provider subjects, MFA factors, recovery challenges, and sessions are all tenant-scoped, and tenant identities stay separate even when accounts are explicitly linked. An identity has: * a `kind`: `user` for people, `service` for machines; * a `status`: `active`, `disabled`, or `deleted` (a tombstone: a record kept without email or secrets so audit history still resolves); * the `owner` and `rootAdmin` flags; * optional typed `attributes` declared by `permissions.identityAttributes`, which policies read as `principal.{name}` (see [resources and catalog](/docs/guides/concepts/resources-and-catalog#identity-attributes)); * an optional `managerId` (another active identity of the tenant) and an optional `expiresAt`, which schedules deactivation for contractors and temporary accounts. ## Adding people to a tenant [#adding-people-to-a-tenant] People join an existing tenant in three ways. Pick the one that matches who knows the person's details and who should choose their password. | Way | Call | When to use it | | ---------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Administrator creates the identity | `identities.create` creates one person; `identities.createMany` creates up to 100 at once | Migrations and bulk onboarding. Needs `iam:identities:create`. `createMany` applies attributes, roles, and groups under the caller's authority, atomically: one failure rejects the batch. A password is optional; without one, send a reset email with `identities.requestPasswordReset` so the person can choose it. | | Self-registration | `auth.signUp` lets a visitor create their own account | Open products where anyone may join a tenant. Only when `authentication.signUpEnabled` is on (off by default), and never in the root tenant. See [sign-in methods](/docs/guides/authentication/sign-in-methods#self-registration). | | Member invitation | `identities.invite` sends the invitation; `identities.acceptInvitation` redeems it | The usual way to add a colleague. The person proves they own the address and chooses their own password; roles and groups are applied at acceptance. | ### Member invitations [#member-invitations] Invitations let an administrator decide **what** a new member gets while the member decides their own password. Nobody else ever handles it, and the invitee proves they control the address by following the link. ### Invite [#invite] `identities.invite` records the invited email with optional roles and groups, stores only a hash of the token, and queues a `member-invitation` delivery. ```ts const invitation = await iam.api.identities.invite(adminCredential, { tenantId, email: 'alice@example.com', roleIds: [editorRoleId], groupIds: [designGroupId], }); ``` The inviter needs `iam:identities:create`, plus `iam:bindings:create` on each role and `iam:groups:update` on each group, so an invitation can never grant more than the inviter could bind directly. Protected owner roles cannot be invited into (`PROTECTED_RESOURCE`). ### Accept [#accept] Your invitation page calls the public `identities.acceptInvitation` with the token from the email link. ```ts const result = await client.identities.acceptInvitation({ tenantId, token, password }); if ('mfaRequired' in result) { // continue with the second factor; see the MFA guide } ``` Acceptance creates the identity with a verified email and applies the bindings under the inviter's grant authority, which is re-validated at that moment. A revoked authority or a revoked invitation makes the token useless (`INVITATION_INVALID`). Separation-of-duties rules are checked, the acceptance is audited as `identity:invitation:accept`, and the first session is issued subject to the tenant's MFA requirements. ### Manage [#manage] * `identities.listInvitations` shows the tenant's member invitations, who sent each, and whether it was consumed or revoked. * `identities.resendInvitation` sends a fresh link with a new lifetime when the first one expired or got lost; the earlier link stops working. * `identities.revokeInvitation` cancels an invitation that should no longer be accepted. ## Root administration [#root-administration] Someone has to run the platform itself: create organizations, set plan limits, and help when a customer is locked out. That power belongs to **root administrators**. Because it reaches every tenant, it is guarded more tightly than anything else. Root authority is a protected boolean capability on a human identity in the root tenant. It is validated from current storage on every use and requires an MFA user session. A role called `root-admin`, a matching email, a linked account, or a JWT claim cannot confer it, and ordinary root-tenant accounts do not inherit it. Root overrides policy restrictions across tenants, but not malformed input, expired credentials, CSRF, signature validation, or resource ownership validation. Actions taken through the override are audited with `rootOverride`. | Operation | What it does | | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `iam.bootstrap({ email, name, password, rootName?, slug? })` | Creates the root tenant and the first root administrator. Runs only against an uninitialized installation (`ALREADY_INITIALIZED` otherwise) and is audited as `root:bootstrap`. | | `iam.recoverRoot({ email, name, password })` | A deployment-operator command that creates a new root administrator when the existing ones are locked out, audited as `root:recover`. Use an email the root tenant does not use yet (`IDENTITY_EXISTS` otherwise); it fails with `NOT_INITIALIZED` before bootstrap. | | `root.setAdministrator({ tenantId, identityId, enabled })` | Grants or removes the capability (`iam:root:grant`, root only, recent authentication). Only human identities of the root tenant qualify, and the identity's sessions are revoked. | | `root.listAdministrators({ tenantId })` | Lists root administrators; root only. | Both `bootstrap` and `recoverRoot` return `mfaEnrollmentRequired: true`: a new root administrator enrolls an authenticator on first sign-in, because root always requires MFA. The last active root administrator cannot be removed, disabled, or deleted (`LAST_ROOT_ADMIN`). > **Protect the deployment.** Access to configuration, database credentials, and the ability to run recovery are root-equivalent capabilities. `bootstrap` and `recoverRoot` are never HTTP endpoints; run them from the CLI. ### Owners [#owners] Owners are the people who control a tenant: its protected Owner role allows every action in the tenant, and they are the ones who can change its policies and transfer ownership. Owner role definitions cannot be edited, and ownership moves only through `identities.setOwner`, which grants or removes the Owner role for a member. Only an owner or a root administrator may call it, with recent authentication. The last active owner of a tenant is protected (`LAST_OWNER`). Only another owner of the same tenant, or root, can change an owner's sign-in address or trigger their password reset; owners cannot be impersonated. ## Account linking [#account-linking] Some people belong to several organizations: a consultant working for two clients, or a founder who also has a personal workspace. Because identities are per tenant, that person has several separate accounts. **Account linking** lets them switch between those accounts from one place, like an account switcher, without merging them. Linking is opt-in (`onboarding: { mode: 'linked' }`); otherwise it fails with `LINKING_DISABLED`. A link never merges roles, credentials, or profiles, and it supplies no cross-tenant permission: each account keeps its own password, factors, and roles. * **Create.** `links.create({ targetCredential })` links the caller's account to the account whose credential they present, proving they control both. It needs recent authentication on **both** accounts. Only ordinary user sessions of two different tenants can link, and root administrators never can (`INVALID_LINK`). Audited as `identity:link`. * **List.** `links.list` returns the current identity's linked accounts (name, email, tenant name and alias) for a switcher UI. * **Switch.** `links.switch({ linkId, targetCredential })` needs a valid link plus a recently authenticated credential for the target, then creates a fresh target session. This deliberately enforces the target's MFA without treating the link as a reusable bearer credential. Audited as `identity:switch`. * **Revoke.** `links.revoke({ linkId })` removes a link the person no longer wants; either side may call it, with recent authentication. Audited as `identity:unlink`. An organization's creator can also link their existing account to the new owner identity while accepting the owner invitation, by passing `linkCredential` to `tenants.acceptInvitation`. Federation uses a separate, stricter mapping: `(tenant, provider, issuer, subject)` to an identity. A verified email from a provider may provision a new account, but an email that already belongs to an identity fails with `ACCOUNT_LINK_REQUIRED`, and the application must complete an explicit, verified linking flow. No email-only association is ever performed. See [OAuth sign-in](/docs/federation/oauth-sign-in). ## Suspension, deletion, and moves [#suspension-deletion-and-moves] Customers stop paying, reorganize, or leave, and people change jobs. These operations take access away cleanly: suspension is reversible and immediate, deletion keeps an audit trail and removes data only after a retention window, and moves keep the tree's rules intact. ### Tenants [#tenants] | Change | Call | Semantics | | ---------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Suspend | `tenants.setStatus({ status: 'suspended' })` | Inherited at authentication and authorization time by the whole subtree. Existing sessions in the subtree, and role sessions sourced from it, are removed. | | Reactivate | `tenants.setStatus({ status: 'active' })` | The parent must be active. Removed sessions are **not** restored; people sign in again. | | Delete | `tenants.setStatus({ status: 'deleted' })` | Needs `iam:tenants:delete`. Tombstones the subtree, stamps `deletedAt` to start the retention window, and revokes its sessions. Audit records remain. | | Purge | `iam.purgeDeleted({ retentionMs })` or the `purge` CLI command | Removes tombstoned tenants past the retention window (30 days by default). Plugin-owned records go through plugin `purge` callbacks in the same transaction; slugs and domain claims are released. | | Rename | `tenants.update({ name })` | Recent authentication and `iam:tenants:update`. | | Move | `tenants.reparent({ parentId })` | Additionally requires authority in the new parent, an active new-parent ancestry, a permitted child type, and depth and cycle checks. Delegation chains keep their original grant authorities. Audited as `tenant:reparent`. | Status changes, renames, and moves require recent authentication. The root tenant cannot be suspended, deleted, or moved, a pending tenant can only be deleted, and deleted tenants cannot be changed (`INVALID_TRANSITION`). ### Identities [#identities] | Change | Call | Semantics | | ---------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Disable or enable | `identities.setStatus` | Disabling revokes every session. An identity past its `expiresAt` cannot be re-enabled until the expiry is extended or cleared (`INVALID_TRANSITION`). | | Scheduled deactivation | `expiresAt` on create or update (in the future, within ten years; `null` clears it) | For contractors and temporary accounts. Past that time the server refuses the identity's sessions and keys for operations and authorization checks; the purge worker then disables it and records `identity:expire`. | | Offboard | `identities.offboard` | Disables the identity and removes its access in one transaction. See [lifecycle](/docs/guides/privileged-access/lifecycle). | | Delete | `identities.delete`, `serviceAccounts.delete` | See below. | Delete an identity when the person or integration is gone for good and you no longer need their record active. `identities.delete` (and `serviceAccounts.delete`) removes a person or service account under `iam:identities:delete` with recent authentication. In one transaction, it removes or revokes the identity's sessions, API keys, factors, passkeys, bindings, group memberships, boundaries, external identity mappings, pending access requests, and links, and revokes the delegated authorities the identity issued. A tombstone with `status: 'deleted'`, no email, and no secrets remains so audit records stay resolvable. The last active owner and the last root administrator are protected, and `identities.list` omits tombstones unless `includeDeleted` is set. Other administrative operations on people: * `identities.update` renames a member, replaces declared attributes, or changes the email. An email change needs recent authentication; the address becomes unverified, sessions are revoked, and `identity:email-change` is audited. * `identities.export` answers a data-subject access request, such as one under the GDPR. It needs recent authentication and `iam:identities:read` on the identity, and returns everything the tenant stores about the person as JSON: the public identity, sessions without token hashes, MFA enrollment and passkey identifiers, external provider subjects, effective bindings, groups, relationships, access requests, boundaries, grant authorities, links, SCIM links, and, when the caller also holds `iam:audit:read`, the audit events the identity performed. Each export is audited as `identity:export`. * `identities.revokeSessions` ends one identity's sessions without disabling it, and `tenants.revokeSessions` ends every session in a tenant. See [sessions](/docs/guides/authentication/sessions#listing-and-ending-sessions). * `identities.unlock` clears rate-limit lockouts and `identities.requestPasswordReset` queues a reset email. See [recovery](/docs/guides/authentication/recovery). * `identities.impersonate` opens an audited "view as" session. See [impersonation](/docs/guides/authentication/impersonation). ## Identities and service accounts [#identities-and-service-accounts] Integrations, scheduled jobs, and other services need to call your API without a person signing in. Give them a service account rather than a shared human login: it has its own roles, its own audit trail, and keys you can rotate or revoke without affecting anyone. Service accounts are identities with `kind: 'service'`. They live in the same directory, are managed under the same `iam:identities:*` actions, and receive roles the same way, but they authenticate differently. `serviceAccounts.create` creates one, `serviceAccounts.update` changes its name, description, attributes, or expiry, `serviceAccounts.setStatus` disables or re-enables it, and `serviceAccounts.delete` removes it and its keys. | | People (`kind: 'user'`) | Service accounts (`kind: 'service'`) | | -------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | Created with | `identities.create`, `createMany`, `invite`, `auth.signUp`, federation, SCIM | `serviceAccounts.create({ tenantId, name, description?, expiresAt? })` | | Credential | User sessions from a sign-in ceremony | API keys from `credentials.create`, sent as `Authorization: Bearer` | | Email, password, MFA | Yes | None | | Expiry | Optional `expiresAt` | Optional `expiresAt`; keys expire too (90 days by default) | | Seen by policies as | `principal.kind: 'user'`, `principal.sessionKind: 'user'` | `principal.kind: 'service'`, `principal.sessionKind: 'api-key'` | | Impersonation | Possible when the tenant allows it | Never | API keys are labeled, can be scoped to a list of actions or a session policy, record `lastUsedAt`, and keep the ceiling of the authority that issued them. Rotation invalidates the old key transactionally. `credentials.list({ unusedForMs })` finds keys nobody uses. See [sign-in methods](/docs/guides/authentication/sign-in-methods#service-accounts-and-api-keys). ## Next steps [#next-steps] - [Resources and catalog](/docs/guides/concepts/resources-and-catalog): The actions and resource types that roles and policies are written against. - [Authentication](/docs/guides/authentication): How the identities in a tenant sign in, and what their sessions carry. # Permission catalog (/docs/guides/authorization/catalog) > The actions and resource types policies may name, from built-in iam:* actions to product, plugin, and tenant-defined types and their resources. Policies refer to actions and resources by name. Without a list of valid names, a typo such as `document:read` instead of `documents:read` would be stored happily and then silently match nothing, and nobody would notice until someone was refused. The permission catalog is that list: the set of action names a policy may use, and the resource types those actions apply to. The catalog contains the built-in `iam:*` actions, your product's actions, plugin actions, and, when you enable it, actions that tenants define for themselves. For each resource type it also records which attributes policies can test, which relations people can hold on it, and whether IAM stores its records. Registering an action grants nothing. It only makes the name valid in policies; access still comes from roles. ## Configure the catalog [#configure-the-catalog] You declare your product's part of the catalog once, in the `permissions` option, next to the rest of your configuration. List every action your code checks, grouped by the resource type it applies to: ```ts title="lib/iam.ts" import { betterIam } from 'better-iam'; import { sqliteAdapter } from 'better-iam/adapter-sqlite'; export const iam = betterIam({ database: sqliteAdapter({ filename: './iam.db' }), secret: process.env.BETTER_IAM_SECRET!, baseURL: 'https://app.example.com', permissions: { mode: 'tenant-defined', // or 'catalog' (default) to allow only developer-defined names actions: ['reports:export'], // actions without a resource type resourceTypes: { document: { actions: ['documents:read', 'documents:write'], attributes: { classification: 'string' } }, project: { managed: true, actions: ['projects:read', 'projects:manage'], attributes: { archived: 'boolean' } }, task: { managed: true, parent: 'project', actions: ['tasks:read', 'tasks:write'] }, }, identityAttributes: { department: 'string', contractor: 'boolean' }, }, }); ``` Each resource type accepts: Plugins contribute actions and platform resource types the same way (`actions` and `resourceTypes` on the plugin object), validated exactly like your own. Configuration errors fail at startup with `INVALID_CONFIG`: * Action names cannot start with `iam:` or `tenant/`, and cannot contain `*`, `?`, or whitespace. * Resource type names match `^[a-z][a-z0-9-]{0,63}$`, and a name declared twice (by you or a plugin) is rejected. * These names are reserved for the platform: `iam`, `role`, `oauth-client`, `scim`, `saml`, `ssf`, `tenant`, `identity`, and `session`. ## How documents are validated [#how-documents-are-validated] The catalog pays off when a document is saved: mistakes are refused on the spot instead of surfacing later as unexplained denials. Policy documents are validated against the catalog when they are stored: * An exact action name that does not exist is rejected with `INVALID_ACTION`. * Once `resourceTypes` is configured, an exact resource type in a resource pattern must also exist (`INVALID_RESOURCE_TYPE`). The platform's own types, such as `iam`, are always accepted. * Wildcard patterns such as `documents:*` or `*/report` are stored as written, without being resolved. The same validation applies to every document the server stores, not only to policies attached to roles. That includes inline role documents, boundaries (ceiling documents that cap what anyone can reach, set on a tenant or on one person), grant-authority ceilings (the cap on what a delegated administrator may hand out), trust ceilings, and session policies, including the policy compiled from an API key's `scopes`. At request time, `iam.authorize` denies an action that is not in the tenant's catalog with the decision reason `UNKNOWN_ACTION`. ## Built-in actions [#built-in-actions] Every operation of the platform is authorized with an `iam:*` action, so administration is delegated with the same policies as your product. The built-in actions are: | Area | Actions | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Tenants | `iam:tenants:create`, `iam:tenants:read`, `iam:tenants:update`, `iam:tenants:delete` | | Identities | `iam:identities:create`, `iam:identities:read`, `iam:identities:update`, `iam:identities:delete`, `iam:identities:impersonate` | | Groups | `iam:groups:create`, `iam:groups:read`, `iam:groups:update`, `iam:groups:delete` | | Roles | `iam:roles:create`, `iam:roles:read`, `iam:roles:update`, `iam:roles:delete`, `iam:roles:assume`, `iam:roles:revoke-sessions` | | Policies | `iam:policies:create`, `iam:policies:read`, `iam:policies:update`, `iam:policies:delete`, `iam:policies:simulate` | | Bindings | `iam:bindings:create`, `iam:bindings:read`, `iam:bindings:delete`, `iam:bindings:activate`, `iam:bindings:approve` | | Access packages | `iam:packages:create`, `iam:packages:read`, `iam:packages:update`, `iam:packages:delete`, `iam:packages:assign`, `iam:packages:request`, `iam:packages:approve` | | Access requests | `iam:access-requests:create`, `iam:access-requests:read`, `iam:access-requests:review` | | Delegation | `iam:authorities:create`, `iam:authorities:revoke`, `iam:boundaries:update`, `iam:root:grant` | | Catalog | `iam:actions:create`, `iam:actions:read`, `iam:actions:delete`, `iam:resource-types:create`, `iam:resource-types:read`, `iam:resource-types:update`, `iam:resource-types:delete` | | Resources | `iam:resources:create`, `iam:resources:read`, `iam:resources:update`, `iam:resources:delete` | | Relationships | `iam:relationships:create`, `iam:relationships:read`, `iam:relationships:delete` | | Credentials and trust | `iam:credentials:create`, `iam:credentials:read`, `iam:credentials:revoke`, `iam:trust:create`, `iam:trust:read`, `iam:trust:update`, `iam:trust:revoke`, `iam:assertions:create` | | Temporary credentials | `iam:session-tokens:create`, `iam:oidc-providers:create`, `iam:oidc-providers:read`, `iam:oidc-providers:update`, `iam:oidc-providers:delete` | | Configuration | `iam:config:read`, `iam:config:apply` | | Audit and webhooks | `iam:audit:read`, `iam:webhooks:create`, `iam:webhooks:read`, `iam:webhooks:update`, `iam:webhooks:delete` | | OAuth provider | `iam:oauth:clients:create`, `iam:oauth:clients:read`, `iam:oauth:clients:update`, `iam:oauth:clients:delete`, `iam:oauth:grants:read`, `iam:oauth:grants:revoke` | | SCIM | `iam:scim:connections:create`, `iam:scim:connections:read`, `iam:scim:connections:delete`, `iam:scim:credentials:create`, `iam:scim:credentials:revoke`, `iam:scim:mappings:update`, `iam:scim:targets:create`, `iam:scim:targets:read`, `iam:scim:targets:update`, `iam:scim:targets:delete`, `iam:scim:targets:sync` | | SAML | `iam:saml:connections:create`, `iam:saml:connections:read`, `iam:saml:connections:update`, `iam:saml:connections:delete` | | Shared Signals | `iam:ssf:streams:create`, `iam:ssf:streams:read`, `iam:ssf:streams:update`, `iam:ssf:streams:delete` | | Domains | `iam:domains:create`, `iam:domains:read`, `iam:domains:update`, `iam:domains:delete` | | Governance | `iam:analysis:read`, `iam:analysis:update`, `iam:certifications:read`, `iam:certifications:review`, `iam:certifications:manage`, `iam:sod:read`, `iam:sod:manage`, `iam:invariants:read`, `iam:invariants:manage`, `iam:agreements:read`, `iam:agreements:manage` | | Network security | `iam:security:read`, `iam:security:manage` | Most names say what they allow: `create`, `read`, `update`, and `delete` on the area's records. The less obvious ones: * `iam:identities:impersonate` starts a "view as" impersonation session for a member. * `iam:roles:assume` starts a role session through a trust, and `iam:roles:revoke-sessions` ends the live role sessions of a role. * `iam:bindings:activate` activates an eligible binding one holds, and `iam:bindings:approve` decides activation requests ([Just-in-time elevation](/docs/guides/privileged-access/elevation)). * `iam:packages:assign`, `iam:packages:request`, and `iam:packages:approve` grant, ask for, and decide on [access packages](/docs/guides/privileged-access/access-packages). * `iam:boundaries:update` sets tenant and principal boundaries, and `iam:root:grant` makes someone a root administrator; both are for root administrators only. * `iam:assertions:create` issues signed assertions for downstream services, and `iam:session-tokens:create` issues short-lived session tokens from one's own session or API key. * `iam:oidc-providers:*` registers external OpenID Connect providers whose tokens can be exchanged for role sessions. * `iam:config:apply` applies [configuration as code](/docs/guides/privileged-access/config-as-code), and `iam:analysis:update` suppresses access-analysis findings. To see the whole catalog of a tenant, call `iam.api.actions.list`. It returns every action a policy in that tenant may name, each marked `platform` (declared in configuration or built in) or `tenant` (registered by the tenant), with the resource type it belongs to. Use it to build a policy editor or to check which names exist. ### Administrative resources [#administrative-resources] Giving someone `iam:roles:update` on `*` lets them edit every role in the tenant. Often you want less: a team lead who manages only their team's roles, or a project admin who registers only that project's tasks. That is possible because every administrative operation is evaluated against an `iam/...` resource naming exactly what it touches, and resource patterns can narrow it: | What the operation does | Checked against | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | | Creates something tenant-wide or reads across the tenant: creating roles and policies (`roles.create`, `policies.create`), listing policies (`policies.list`), registering resource types (`resourceTypes.register`), asking who can reach a resource (`policies.whoCan`) | `iam/{tenantId}` | | Reads or changes one role (`roles.get`, `roles.update`, `roles.delete`), binds it to someone or edits such a binding (`bindings.create`, `bindings.update`, both under `iam:bindings:create`), activates it or approves an activation (`bindings.activate`, `bindings.approveActivation`) | `iam/{roleId}` | | Reads, edits, rolls back, or deletes one stored policy (`policies.get`, `policies.update`, `policies.restoreVersion`, `policies.delete`) | `iam/{policyId}` | | Removes one binding (`bindings.delete`) | `iam/{bindingId}` | | Renames a group, changes its members, or deletes it (`groups.update`, `groups.addMember`, `groups.removeMember`, `groups.delete`) | `iam/{groupId}` | | Registers, reads, changes, or deletes one managed resource (`resources.register`, `resources.get`, `resources.update`, `resources.delete`), or shares it (`relationships.create`) | `iam/{type}/{id}` | | Lists resources or relationships (`resources.list`, `relationships.list`) | `iam/{type}/*` with a type filter, otherwise `iam/*` | | Starts a role session (`roles.assume`, evaluated in the caller's own tenant) | `iam/{roleId}` of the target role | | Acts on one identity: delegating authority to it, issuing it an API key, or simulating its access (`authorities.create`, `credentials.create`, `policies.simulate`, `policies.effectiveActions`) | `iam/{identityId}` | | Creates, lists, or reports on separation-of-duties rules (`sod.create`, `sod.list`, `sod.violations`); changes or deletes one (`sod.update`, `sod.delete`) | `iam/sod/*`; `iam/sod/{ruleId}` | For example, this policy lets its holders register and update the tasks of one project, and nothing else: ```json title="Delegated administration of one project's tasks" { "version": 1, "statements": [ { "sid": "RegisterApolloTasks", "effect": "allow", "actions": ["iam:resources:create", "iam:resources:update"], "resources": ["iam/task/apollo-*"] } ] } ``` ## Resource types and resources [#resource-types-and-resources] A decision needs facts about the resource: which tenant it belongs to (so nobody reaches another tenant's data), who owns it, and the attributes conditions test. Before evaluating a request, the server **resolves** the resource to learn these facts. Resource patterns in policies then match `type/id` within that already-resolved tenant; `*` and `?` are anchored glob wildcards, not regular expressions, and they cannot change the target tenant. Where the facts come from depends on the type, which is either **application-owned** or **managed**: | | Application-owned | Managed | | ------------------------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------- | | Records live in | Your database | Better IAM's database | | Resolved by | Your `resolveResource` callback | The IAM registry, no callback | | Owner and parent | Whatever your resolver returns as attributes | Built in: `ownerId` and `parentId`, validated on registration | | Relationship tuples | Accepted for the resource as named | Accepted once the resource is registered | | Listable with `listAccessible` | No | Yes | | Choose it when | Your product already stores the records and their tenant | You want IAM to hold ownership and sharing, or to list what a person may open | ### Application-owned types [#application-owned-types] Application-owned types are resolved by your `resolveResource` callback. It receives the requested tenant, type, and ID, and must load ownership and attributes from trusted storage. Types that are not declared at all are still passed to `resolveResource`, so existing integrations keep working; declaring them adds validation and documents the attribute schema. ```ts title="lib/iam.ts" import { betterIam, IamError } from 'better-iam'; export const iam = betterIam({ // ...database, secret, baseURL, permissions async resolveResource({ type, id }) { if (type !== 'document') throw new IamError('NOT_FOUND', 'Unknown resource type', 404); const document = await db.documents.findById(id); if (!document) throw new IamError('NOT_FOUND', 'Document not found', 404); return { tenantId: document.organizationId, // from your storage, never from the request type, id, attributes: { classification: document.classification, ownerId: document.ownerId }, }; }, }); ``` > **Never echo the requested tenant.** The resolver is what proves a resource belongs to the tenant being authorized. Never copy the request's tenant ID into a fetched record to satisfy it. When the returned tenant, type, or ID differs from the request, the check fails with `RESOURCE_MISMATCH` (403). Without a resolver, application resources fail with `RESOURCE_RESOLVER_REQUIRED`. ### Managed types [#managed-types] Managed types (`managed: true`, and every tenant-defined type) are registered with IAM, so authorization resolves the registration and no application callback is involved. Your code tells IAM when a resource is created, changed, or deleted, through [`iam.api.resources`](/docs/reference/api/resources). `resources.register` records a new resource with its attributes, owner, and parent: ```ts title="Registering managed resources" await iam.api.resources.register(credential, { tenantId, type: 'project', id: 'apollo', attributes: { archived: false }, ownerId: alice.id, }); // A task needs its registered parent project. await iam.api.resources.register(credential, { tenantId, type: 'task', id: 'apollo-42', parentId: 'apollo' }); ``` * Attribute values are validated against the declared schema (string values up to 2048 characters). * `ownerId` must be an identity of the same tenant. * Types with a `parent` require a registered parent resource of the declared parent type; a type without one refuses `parentId`. * Registering a resource requires `iam:resources:create` on `iam/{type}/{id}`, so policies can scope who may register which resources. Registering the same resource twice fails with `CONFLICT`. * `resources.update` keeps the registration in step with your data: it replaces the attributes (validated again) and sets or clears (`ownerId: null`) the owner. Call it when a document is reclassified or changes hands. * `resources.get` returns one registration, and `resources.list` lists them for administration screens, filtered by `type`, `parentId`, and `ownerId`, ordered by type and ID, with `limit` (default 100, at most 1000) and `offset`. To list what a person may open, use `listAccessible` instead. * `resources.delete` removes a registration when the resource is deleted. It refuses while child resources exist (`RESOURCE_IN_USE`) and removes the resource's relationships with it. * `resources.registerMany` registers up to 100 resources in one transaction, for imports and backfills; see [Batches and reverse queries](/docs/guides/authorization/queries#register-resources-in-bulk). A tenant's `resources` plan limit, when set, caps how many it may register (`LIMIT_EXCEEDED`). Managed resource registrations are trusted authorization inputs: the `iam:resources:*` permissions decide who may register or edit them. Only managed types can be listed with [`listAccessible`](/docs/guides/authorization/queries#list-accessible-resources), the reverse query behind list pages. For application-owned types, check the IDs your product already has with `authorizeMany`. ### What policies see [#what-policies-see] The resolved resource becomes context for conditions, the tests inside policy statements. The principal below is the caller: | Key | Source | | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `resource.{name}` | Attributes from the resolver or the registration. | | `resource.ownerId`, `resource.parentId`, `resource.parentType` | Managed resources, when set. | | `resource.tenantId` | The tenant the resource belongs to. | | `resource.relations`, `resource.parentRelations` | Relations the principal holds on the resource and its parent. See [Relationships](/docs/guides/authorization/relationships). | For administrative actions on `iam/{type}/{id}` that name a registered managed resource, these keys describe that resource, so administration such as sharing can be conditioned on ownership and relations. The full list of keys is on the [Conditions](/docs/guides/authorization/conditions#context-keys) page. ## Tenant-defined catalogs [#tenant-defined-catalogs] Some products let each customer model their own objects: a workflow tool where one customer tracks invoices and another tracks shipments. You cannot declare those types in advance. With `mode: 'tenant-defined'`, tenant administrators extend the catalog for their own organization, and the new types and actions work in their policies exactly like yours. Tenant types are always managed, so IAM stores their records. `resourceTypes.register` creates a type with its actions, attributes, and relations in one call, and `actions.register` adds one more action to an existing tenant type: ```ts title="A tenant registers its own type" await iam.api.resourceTypes.register(credential, { tenantId, name: 'invoice', description: 'Supplier invoices', actions: ['read', 'approve'], // registers invoice:read and invoice:approve attributes: { amount: 'number', currency: 'string' }, relations: ['approver'], }); await iam.api.actions.register(credential, { tenantId, name: 'invoice:export', description: 'Download as CSV' }); ``` * Tenant type names cannot collide with reserved names, platform resource types, or the namespace of any platform action (`documents` is taken by `documents:read`), and fail with `INVALID_RESOURCE_TYPE`. A name the tenant already registered fails with `CONFLICT`. * A `parent` must be an existing managed type. * Tenant-defined actions are namespaced under a tenant-defined type as `{type}:{verb}`. `actions.register` and the `actions` list on `resourceTypes.register` (verbs only) create them; the type must be registered first (`INVALID_ACTION` otherwise). * `resourceTypes.update` evolves a type: it changes the description, attributes, and relations and adds verbs; existing verbs are kept. A new attribute schema must still accept every registered resource of the type, and a relation still held by someone cannot be dropped (`RESOURCE_IN_USE`). * Deleting a type or action is refused while resources or policies still use it. `resourceTypes.delete` removes a type once its resources, relationships, and child types are gone. `actions.unregister` removes one action and refuses while a policy or inline role document names it (`RESOURCE_IN_USE`). * With `mode: 'catalog'`, every tenant registration fails with `CATALOG_LOCKED` (403). The permissions are `iam:resource-types:create`, `read`, `update`, and `delete`, and `iam:actions:create`, `read`, and `delete`. `resourceTypes.list` returns platform and tenant types together, each with its `source`, and `resourceTypes.get` returns one of them with its actions, attributes, and relations. Configuration as code carries tenant-defined resource types (with `actions` as verbs), so a tenant's catalog can live in version control with the roles that use it. See [Configuration as code](/docs/guides/privileged-access/config-as-code). ## Identity attributes [#identity-attributes] Roles answer "what job does this person do?". Some rules depend on facts about the person instead: their department, whether they are a contractor, their clearance level. Identity attributes carry those facts, so one statement can cover everyone in finance without a role per department. Declare the attributes in `permissions.identityAttributes`. An administrator then sets them on a person with `identities.update` (or on a service account with `serviceAccounts.update`), and policies read them as `principal.{name}`: ```ts await iam.api.identities.update(credential, { tenantId, identityId, attributes: { department: 'finance' } }); ``` ```json title="A condition on the attribute" { "StringEquals": { "principal.department": "finance" } } ``` They cannot shadow the built-in principal keys such as `principal.id`, `principal.mfa`, or `principal.roles`, nor the session keys such as `principal.mfaTime` or `principal.sessionTags`; declaring one fails at startup with `INVALID_CONFIG`. ## Next steps [#next-steps] - [Roles and bindings](/docs/guides/authorization/roles): Turn catalog actions into roles and give them to people and groups. - [Policy documents](/docs/guides/authorization/policies): Write statements over actions and resource patterns. - [Resources and the catalog](/docs/guides/concepts/resources-and-catalog): The concepts behind resources, types, and tenants. # Conditions (/docs/guides/authorization/conditions) > Every condition operator, how values combine, negation and missing-key rules, and the context keys the server provides to policies. Roles and resource patterns answer "who may do what, on which things". Real rules often depend on the situation too: only from an MFA-verified session, only on documents you own, only until the end of the audit, only after accepting the terms of use. A condition adds such a test to a policy statement, and the statement applies only when every condition holds. Each condition compares one key of the request **context** with the values you list. The context is a set of facts the server assembles for every decision from trusted sources: about the principal (the person or service account making the request), such as `principal.mfa`; about the resource, such as `resource.ownerId`; and about the request, such as `request.time`. ```json { "effect": "allow", "actions": ["documents:write"], "resources": ["document/*"], "conditions": { "Bool": { "principal.mfa": true }, "StringEquals": { "principal.kind": "user", "resource.classification": ["internal", "public"] } } } ``` This statement applies to MFA-verified people, on documents classified `internal` or `public`. The keys of `conditions` are **operators** (`Bool`, `StringEquals`), each operator maps context keys to the expected values, and a key can list several values. Try this statement in the playground Change `resource.classification` to `secret`, or `principal.mfa` to `false`, and the statement stops applying. With no other statement allowing the write, the decision becomes an implicit deny. ## How conditions combine [#how-conditions-combine] A few rules decide how several conditions, keys, and values add up: * **Operators and keys are ANDed.** Every operator in `conditions`, and every key under each operator, must hold. * **Listed values are ORed.** A key with several values holds when any of them matches. * **Except for negated operators and `ArrayContainsAll`**, which must hold for every listed value: `StringNotEquals` with `["secret", "restricted"]` means "neither secret nor restricted". * **Types are strict.** A missing or wrongly typed context value never satisfies an operator, including the negated ones. `StringNotEquals` on an absent key is false, not vacuously true. Use `Exists` to test absence. * **Invalid syntax is rejected early.** Unknown operators and values of the wrong type fail with `INVALID_POLICY` when a policy is created or updated. ## Operators [#operators] Better IAM supports 21 operators, grouped by the kind of value they compare. Every operator accepts one value or a list of up to 64 values per key. ### Text [#text] Use the string operators for identifiers, names, and labels: the kind of account, a department, a classification. The `IgnoreCase` variants help with values people type, and the `Like` variants accept `*` and `?` wildcards. | Operator | In plain English | Example | | --------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------- | | `StringEquals` | The value is exactly one of these strings (case matters). | `{ "StringEquals": { "principal.kind": "service" } }` | | `StringNotEquals` | The value is a string, and none of these. | `{ "StringNotEquals": { "resource.classification": ["secret", "restricted"] } }` | | `StringEqualsIgnoreCase` | The value is one of these strings, ignoring upper and lower case. | `{ "StringEqualsIgnoreCase": { "principal.department": "Finance" } }` | | `StringNotEqualsIgnoreCase` | The value is a string, and none of these, ignoring case. | `{ "StringNotEqualsIgnoreCase": { "principal.department": "contractors" } }` | | `StringLike` | The value matches one of these wildcard patterns. | `{ "StringLike": { "principal.department": "eng-*" } }` | | `StringNotLike` | The value is a string that matches none of these patterns. | `{ "StringNotLike": { "resource.name": "tmp-*" } }` | | `StringLikeIgnoreCase` | The value matches one of these patterns, ignoring case. | `{ "StringLikeIgnoreCase": { "principal.department": "ENG-*" } }` | The values of the string operators may contain policy variables, such as `"${principal.id}"`; see [Policy variables](/docs/guides/authorization/policies#policy-variables). ### Yes or no [#yes-or-no] Use `Bool` for flags: whether the session used MFA, whether the person is an owner, whether a project is archived. | Operator | In plain English | Example | | -------- | ------------------------------------------------------- | --------------------------------------- | | `Bool` | The value is `true` (or `false`), and really a boolean. | `{ "Bool": { "principal.mfa": true } }` | ### Numbers [#numbers] Use the numeric operators for amounts, levels, and counts: an invoice total, a clearance level, how many required agreements are still owed. | Operator | In plain English | Example | | -------------------------- | ----------------------------------------- | ---------------------------------------------------------------- | | `NumericEquals` | The value is one of these numbers. | `{ "NumericEquals": { "principal.clearance": 3 } }` | | `NumericNotEquals` | The value is a number, and none of these. | `{ "NumericNotEquals": { "resource.stage": 0 } }` | | `NumericLessThan` | The value is below this number. | `{ "NumericLessThan": { "resource.amount": 10000 } }` | | `NumericLessThanEquals` | The value is at most this number. | `{ "NumericLessThanEquals": { "resource.amount": 10000 } }` | | `NumericGreaterThan` | The value is above this number. | `{ "NumericGreaterThan": { "principal.pendingAgreements": 0 } }` | | `NumericGreaterThanEquals` | The value is at least this number. | `{ "NumericGreaterThanEquals": { "principal.clearance": 2 } }` | ### Dates and times [#dates-and-times] Use the date operators to make access start or end at a fixed moment, usually by testing `request.time`. | Operator | In plain English | Example | | ------------ | -------------------------------------------- | -------------------------------------------------------------- | | `DateBefore` | The value is a moment earlier than this one. | `{ "DateBefore": { "request.time": "2026-12-31T23:59:59Z" } }` | | `DateAfter` | The value is a moment later than this one. | `{ "DateAfter": { "request.time": "2026-10-01T00:00:00Z" } }` | ### Network addresses [#network-addresses] Use the IP operators to allow or block networks, such as an office range or a VPN. The server puts the caller's address in `request.sourceIp` when it knows it (see [Resource and request keys](#resource-and-request-keys)). | Operator | In plain English | Example | | -------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `IpAddress` | The value is an IP address inside one of these networks. | `{ "IpAddress": { "request.sourceIp": ["203.0.113.0/24", "2001:db8::/32"] } }` | | `NotIpAddress` | The value is a valid IP address outside all of these networks. | `{ "NotIpAddress": { "request.sourceIp": "10.0.0.0/8" } }` | ### Lists [#lists] Some context keys hold lists: the groups a person belongs to, their roles, the relations they hold on a resource. Use the array operators for them; the string operators never match a list. | Operator | In plain English | Example | | ------------------ | ----------------------------------------------- | ----------------------------------------------------------------------- | | `ArrayContains` | The list includes at least one of these values. | `{ "ArrayContains": { "principal.groups": "grp_admins" } }` | | `ArrayContainsAll` | The list includes every one of these values. | `{ "ArrayContainsAll": { "resource.relations": ["editor", "owner"] } }` | The values listed for `ArrayContains` and `ArrayContainsAll` may contain policy variables too. ### Presence [#presence] Use `Exists` to test whether a key is there at all, which matters because a missing key fails every other operator (see [Missing keys](#missing-keys)). | Operator | In plain English | Example | | -------- | ------------------------------------------------ | ------------------------------------------------ | | `Exists` | The key is present (`true`) or absent (`false`). | `{ "Exists": { "principal.authMethod": true } }` | ## Negated operators [#negated-operators] "Not equal to secret" sounds like it should hold for a document with no classification at all. In Better IAM it does not, and that is deliberate: a negated test on a missing or malformed value would otherwise quietly let requests through. The negated operators are `StringNotEquals`, `StringNotEqualsIgnoreCase`, `StringNotLike`, `NumericNotEquals`, and `NotIpAddress`. A negated operator only negates a comparison that could have succeeded, so the context value must first have the operator's type: | Operator | The context value must be | | --------------------------------------------------------------- | ------------------------- | | `StringNotEquals`, `StringNotEqualsIgnoreCase`, `StringNotLike` | a string | | `NumericNotEquals` | a finite number | | `NotIpAddress` | a valid IP address | If it is not (missing, a list, a number where a string is expected, text that is not an address), the condition is false: an allow with `NotIpAddress` does not apply to a malformed address, and neither does a deny. ## Missing keys [#missing-keys] A key that is absent from the context never satisfies any operator except `Exists`. For allow statements this is the safe direction: the statement simply does not apply. For deny statements it is a trap, because the deny also does not apply. > **Guard denies on optional keys.** Keys such as `principal.authMethod`, `principal.mfaTime`, `request.sourceIp`, session tags, identity attributes, resource attributes, and application keys can be missing. A deny conditioned only on them silently never applies when they are. This pair of statements keeps payments to the finance department, including for people with no department set: ```json title="Deny on an optional key, with its missing-key case" [ { "sid": "OnlyFinance", "effect": "deny", "actions": ["payments:*"], "resources": ["*"], "conditions": { "StringNotEquals": { "principal.department": "finance" } } }, { "sid": "OnlyFinanceWhenUnset", "effect": "deny", "actions": ["payments:*"], "resources": ["*"], "conditions": { "Exists": { "principal.department": false } } } ] ``` See the missing-key case in the playground The request has no `principal.department`, so `OnlyFinance` does not apply and `OnlyFinanceWhenUnset` denies it. Remove the second deny in the playground and the payment is allowed, which is exactly the gap the guard closes. [`analysis.lintPolicy`](/docs/guides/authorization/policies#lint) reports such denies as `optional-key-deny`. ## Value formats [#value-formats] Because types are strict, a value in the wrong format never matches. These are the formats each family expects, both in your policy and in the context: * **Timestamps** for `DateBefore` and `DateAfter` are ISO 8601 with seconds and a zone: `2026-09-22T09:30:00Z`, optionally with one to three fractional digits (`.250`) or an offset (`+02:00`) instead of `Z`. Impossible calendar dates such as February 31 are rejected in policies and never match in the context. `request.time` is always in this format. * **Numbers** must be finite. A string of digits, such as `"3"`, does not match a numeric operator. * **IP addresses and networks** are IPv4 dotted decimal or IPv6 (with `::` compression or an embedded IPv4 suffix), optionally with a `/prefix`. Zone IDs (`%eth0`) and brackets are not accepted. Across families, an IPv4-mapped IPv6 address (`::ffff:198.51.100.7`) stands for its IPv4 address, so how a dual-stack listener spells a client can neither sidestep a block nor get an allowed office refused. * **Globs** in `StringLike`, `StringNotLike`, and `StringLikeIgnoreCase` work like resource patterns: anchored, with `*` and `?` as the only wildcards. * **Array membership** is exact: `ArrayContains` with `3` does not match an array holding `"3"`. `DateBefore` and `DateAfter` compare absolute instants. For recurring hours, such as weekdays from 9 to 5, use an access window on the binding instead; see [Access windows](/docs/guides/authorization/temporary-access#access-windows). ## Context keys [#context-keys] A condition can only test what is in the context, so this list is the vocabulary of your conditions. The server builds the context for every decision from trusted sources: the session, stored identity records, the resolved resource, and your own callbacks. Nothing comes from the browser, so a caller cannot claim to be an owner or to have used MFA. ### Principal keys [#principal-keys] These describe who is calling and how they signed in. | Key | Type | Value | | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `principal.id` | string | The identity's ID. | | `principal.tenantId` | string | The tenant of the session. | | `principal.mfa` | boolean | Whether the session is MFA-verified. Always false for API keys and delegated sessions. | | `principal.kind` | string | `user`, `service`, or `agent` (an [AI agent](/docs/guides/ai-agents)'s own key). A delegated session acts as the person, so it is `user`; test `principal.delegated` for agents acting for people. | | `principal.owner` | boolean | Whether the identity is an owner of this tenant. False in role sessions and delegated sessions. | | `principal.rootAdmin` | boolean | Whether the identity holds root authority. False in role sessions and delegated sessions. | | `principal.sessionKind` | string | The credential: `user`, `api-key`, `role`, `session-token`, or `delegated` (an AI agent acting for a person). | | `principal.authMethod` | string, optional | How a user session was established: `password`, `passwordless-email`, `passwordless-sms`, `passkey`, `federated`, or `impersonation`. Absent for API keys, role sessions, and sessions issued before methods were recorded. | | `principal.impersonated` | boolean | True while an administrator acts as the member. | | `principal.impersonatorId` | string, optional | The administrator, present only during impersonation. | | `principal.groups` | list | IDs of the groups the identity is a live member of. | | `principal.teams` | list | IDs of the person's teams and every team above them. Empty for people without teams and for assumed roles. See [Teams and departments](/docs/guides/teams-and-departments). | | `principal.departments` | list | The person's department ID and every department above it; empty without a department. | | `principal.departmentId` | string, optional | The person's own department, absent when they have none. | | `principal.roles` | list | IDs of the roles bound to the identity directly or through groups, including eligible roles while activated. Roles reached only through inheritance are not listed. | | `principal.agreements` | list | Names of the terms of use accepted in their current version. See [Agreements](/docs/guides/governance/agreements). | | `principal.pendingAgreements` | number | How many required agreements are still owed. | | `principal.spendExceeded` | boolean | Whether an enforced spend budget covering the principal is spent. See [Billing and spend](/docs/guides/billing). | | `principal.budgetsExceeded` | list | Names of every spent budget covering the principal. Budget standings are cached for up to 30 seconds; an assumed role sees only the tenant's budgets. | | `principal.onboarding` | list | Names of the onboarding flows the person completed. See [Onboarding](/docs/guides/onboarding). | | `principal.pendingOnboarding` | number | How many required onboarding flows are still open. | | `principal.delegated` | boolean | Whether an AI agent is acting on the person's behalf. | | `principal.delegationChain` | list, optional | For work [handed on between agents](/docs/guides/ai-agents#handing-work-on-to-other-agents): the agents from the person's own delegate to the acting one. | | `principal.delegationId`, `principal.agentId`, `principal.agentSponsorId`, `principal.agentModel`, `principal.agentProvider` | string, optional | For AI agents: the delegation in use, the agent behind the credential (its own key or a delegated session), the person who sponsors it, and its model and provider. | | `principal.{name}` | declared type, optional | Identity attributes declared in `permissions.identityAttributes`. They cannot shadow the keys above. | The session itself adds a second set of keys. They let a policy ask how fresh a sign-in is ("MFA within the last hour"), or who a temporary session was issued to: | Key | Type | Value | | --------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `principal.sessionId` | string | The ID of the session making the request. | | `principal.tokenIssueTime` | timestamp | When the session was created. | | `principal.authTime` | timestamp | When the person last authenticated in this session. | | `principal.mfaTime` | timestamp, optional | When the second factor was completed. Present only for a first-hand second factor, not for a remembered device or an impersonation session, so a `DateAfter` on it proves recent MFA. | | `principal.sessionTagKeys` | list | The names of the session's tags, sorted; empty for sessions without tags. | | `principal.sessionTags.{key}` | string, optional | The value of one session tag, such as `principal.sessionTags.ticket`. Tags are set when a [role session](/docs/guides/authorization/temporary-access#role-sessions) is started. | | `principal.sourceTenantId` | string, optional | For role sessions, the tenant the caller came from. | | `principal.sessionName`, `principal.sourceIdentity` | string, optional | Names recorded when a temporary session was issued, so audit and policies can attribute it to a person or job. | | `principal.webIdentityProvider`, `principal.webIdentitySubject` | string, optional | For sessions exchanged from an external identity provider's token: which provider, and the subject it vouched for. | ### Resource and request keys [#resource-and-request-keys] These describe the thing being acted on, the caller's relation to it, and when the request happens. | Key | Type | Value | | -------------------------------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `resource.tenantId` | string | The tenant the resource belongs to. | | `resource.{name}` | declared type, optional | Attributes from `resolveResource` or the managed registration. | | `resource.ownerId`, `resource.parentId`, `resource.parentType` | string, optional | Managed resources, when set. | | `resource.relations` | list | Relations the principal holds on the resource, directly or through a group. See [Relationships](/docs/guides/authorization/relationships). | | `resource.parentRelations` | list | Relations the principal holds on the resource's registered parent. | | `request.time` | timestamp | When the request is evaluated. | | `request.sourceIp` | string, optional | The caller's IP address, when the server saw one for this request and it parses as an address (see [client details](/docs/guides/authentication/http#client-details)). Never present in simulations. | ### Tenant keys [#tenant-keys] | Key | Type | Value | | ----------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `tenant.features` | list | Keys of the [feature flags](/docs/guides/feature-flags) that are on for the decision's tenant, internal flags included. Read only for decisions whose documents name it. | The server never sets `resource.type` or `resource.id` (match them in the resource pattern instead), nor `principal.email`, `principal.name`, or `principal.managerId` (declare an identity attribute if a policy needs one). The linter flags these as `unknown-context-key`. ### Application keys [#application-keys] Some rules depend on your own data, such as the customer's billing plan or whether their seats are used up. `resolveContext`, on the server options or on a plugin, adds such server-derived values to the context. It receives the authenticated principal and returns a map of keys, and it runs for every decision, so keep it fast: ```ts title="lib/iam.ts" export const iam = betterIam({ // ... async resolveContext(principal) { const account = await billing.accountFor(principal.session.tenantId); return { 'app.plan': account.plan, 'app.seatsExceeded': account.seatsUsed > account.seats }; }, }); ``` ```json { "effect": "deny", "actions": ["reports:export"], "resources": ["*"], "conditions": { "StringEquals": { "app.plan": "free" } } } ``` Precedence runs from least to most trusted: the application's `resolveContext`, then plugins, then declared identity attributes, and finally the server-derived principal and request keys, which always win. Resource keys are set from the resolved resource. Neither callback may trust arbitrary browser attributes. For network rules, test `request.sourceIp`. It is optional: a request whose address the server did not see (a server-side call without client details, or a simulation) has no such key, so pair a network deny with an `Exists` guard as shown in [Missing keys](#missing-keys). ### Special sessions [#special-sessions] A few kinds of session see a different context, which matters when a condition behaves unexpectedly for them: * **Role sessions**, created by role assumption, carry only the assumed role: `principal.roles` holds the role, `principal.groups` is empty, `principal.owner` and `principal.rootAdmin` are false, no relations apply, and there are no agreements. `principal.authMethod` is absent, while `principal.mfa`, `principal.authTime`, and `principal.mfaTime` carry over from the session that assumed the role. `principal.sourceTenantId` names the caller's own tenant, and the caller's declared identity attributes are included unless the trust withholds them. * **Impersonation** sets `principal.impersonated` and `principal.impersonatorId`, and the decision is also checked against the administrator's own rights. * **Simulations and reviews** evaluate an identity in a synthetic session: `principal.sessionKind` is `user` for people and `api-key` for service accounts, `principal.authMethod` is absent, and `principal.mfa` is false unless you pass `assumeMfa: true`. `principal.sessionId` is `simulation`, `principal.authTime` is the moment of evaluation, and there are no session tags. `principal.mfaTime` and `request.sourceIp` are always absent, even with `assumeMfa`, so a condition that requires recent MFA or a particular network never holds in a simulation. ## Common conditions [#common-conditions] These statements solve problems that come up in most applications. Add them to a role, or to a policy attached to a role everyone holds. ```json title="Administration only from MFA sessions" { "effect": "deny", "actions": ["iam:*"], "resources": ["*"], "conditions": { "Bool": { "principal.mfa": false } } } ``` `principal.mfa` is always present, so this deny is safe without an `Exists` guard. It also blocks API keys, which are never MFA-verified; exclude them with a `StringEquals` on `principal.kind` if integrations administer the tenant. ```json title="Hold back documents until required terms are accepted" { "effect": "deny", "actions": ["documents:*"], "resources": ["*"], "conditions": { "NumericGreaterThan": { "principal.pendingAgreements": 0 } } } ``` ```json title="Only people who accepted an optional agreement" { "effect": "allow", "actions": ["beta:use"], "resources": ["*"], "conditions": { "ArrayContains": { "principal.agreements": ["Beta program"] } } } ``` ```json title="No deletions while viewing as a member" { "effect": "deny", "actions": ["*:delete"], "resources": ["*"], "conditions": { "Bool": { "principal.impersonated": true } } } ``` ```json title="Access that ends on a date" { "effect": "allow", "actions": ["audit-workspace:read"], "resources": ["*"], "conditions": { "DateBefore": { "request.time": "2026-12-31T23:59:59Z" } } } ``` Try any of these in the [policy playground](/playground) with your own context values. ## Next steps [#next-steps] - [Policy variables](/docs/guides/authorization/policies#policy-variables): Compare context keys with each other, such as the owner and the caller. - [Relationships](/docs/guides/authorization/relationships): Conditions on relations the caller holds. - [Access reviews](/docs/guides/authorization/reviews): See how a condition affects real people before you rely on it. # Authorization (/docs/guides/authorization) > How Better IAM decides whether a principal may perform an action on a resource, and where roles, policies, boundaries, and relationships fit in. Authentication tells you who is calling. Authorization decides what they may do. Every protected operation in an application that uses Better IAM asks the same question: may this principal (the person or service account making the request) perform this action (such as `documents:delete`) on this resource (such as `document/q3-plan`), in this tenant? Better IAM answers that question with one evaluator for everything: your product's own checks, the administrative API, the console, and the CLI. You describe access once, in roles and policies, instead of scattering `if (user.isAdmin)` checks through your code. When the rules change, you change a role, not a deployment. In short: people receive roles through bindings, roles are made of JSON policy documents, and boundaries cap what any role can reach. The format is Better IAM's own versioned format, inspired by [AWS's policy evaluation concepts](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_evaluation-logic.html); it is not an AWS JSON-policy parser. You can try policy documents and requests in the [policy playground](/playground), which runs the real evaluator from `@better-iam/core` in your browser. ## The model at a glance [#the-model-at-a-glance] These are the building blocks, and the words the rest of this section uses: | Building block | What it is | Read more | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | | Principal | The caller: a person or service account, authenticated by a session, API key, or role session, always acting in one tenant. | [Tenants and identities](/docs/guides/concepts/tenants-and-identities) | | Action | A verb on a kind of thing, such as `documents:read` or `iam:roles:create`. Policies may only name actions in the permission catalog. | [Permission catalog](/docs/guides/authorization/catalog) | | Resource | The thing being acted on, written `type/id` inside the tenant, such as `document/q3-plan`. Administrative operations use `iam/...` resources. | [Permission catalog](/docs/guides/authorization/catalog#resource-types-and-resources) | | Policy | A versioned JSON document of allow and deny statements. Each statement names actions, resources, and optional conditions. | [Policy documents](/docs/guides/authorization/policies) | | Condition | A test on the request inside a statement, such as "the session used MFA" or "the caller owns the resource". | [Conditions](/docs/guides/authorization/conditions) | | Role | A named job function, such as *Editor*: the union of its attached policies, its inline document, and the roles it inherits. | [Roles and bindings](/docs/guides/authorization/roles) | | Binding | The link that gives one role to one person or group. It can be temporary, future-dated, limited to business hours, or activated on demand. | [Roles and bindings](/docs/guides/authorization/roles#bindings) | | Grant | What a principal receives through its bindings: the allow and deny statements of its roles. Grants add up. | [How a decision is made](#how-a-decision-is-made) | | Boundary | A ceiling document that limits what grants can reach. Boundaries only take access away; they never grant it. | [Boundaries](/docs/guides/authorization/policies#boundaries) | | Grant authority | The delegated right to hand out access. Every role, policy, and binding is created under one, and its ceiling bounds everything issued under it. | [Grant authorities](/docs/guides/authorization/roles#grant-authorities) | | Relationship | A fact such as "alice is an `owner` of `folder/plans`", which policies read as `resource.relations`. | [Relationships](/docs/guides/authorization/relationships) | A request names an action and a resource. The tenant is resolved first, from the request and the credential, so resource patterns only ever match `type/id` inside that tenant and can never reach another one. Here is a policy that lets its holders read any document, but only from an MFA-verified session: ```ts title="A policy document" import { definePolicy } from 'better-iam'; const reader = definePolicy({ version: 1, statements: [ { sid: 'ReadWithMfa', effect: 'allow', actions: ['documents:read'], resources: ['document/*'], conditions: { Bool: { 'principal.mfa': true } }, }, ], }); ``` `definePolicy` checks the document's shape when your code loads and returns a copy you can store in a role or policy. Nothing is granted until the document is attached to a role and the role is bound to someone. ## How a decision is made [#how-a-decision-is-made] Knowing the order of evaluation explains every surprising "access denied": a deny statement somewhere, a missing grant, or a boundary that caps the grant. The server evaluates every request in the same order: 1. Validate the credential, the current identity and tenant, and the requested action and resource. An action outside the catalog is denied (`UNKNOWN_ACTION`); a resource that cannot be resolved is refused. 2. Apply the protected universal root override, but only to authenticated root administrators: a root-tenant person with root authority, signed in with an MFA-verified user session. 3. For every other principal, require an active tenant ancestry and a session that belongs to the target tenant. 4. Collect the role grants of the identity, from its own bindings and its groups' bindings, and intersect them with the tenant, principal, issuer (grant authority), and session ceilings. 5. An applicable explicit deny overrides any allow. Without an effective allow, deny access. The core of the flowchart is the policy evaluator, `evaluatePolicy` from `@better-iam/core`, and its four reasons: | Reason | Meaning | | --------------- | ---------------------------------------------------------------------------------------------------------------------- | | `explicit-deny` | A deny statement matched. A deny in any role you hold applies to every grant, and a deny inside a boundary denies too. | | `no-grant` | No allow statement in the grants matched. Grants form a union: one matching allow is enough. | | `boundary-deny` | A grant allowed it, but at least one boundary has no matching allow. Every boundary is an independent intersection. | | `allowed` | No deny matched, a grant allowed it, and every boundary allowed it. | On the server, grants arrive through grant paths: each binding brings its role's documents together with the ceilings of the authorities that issued the binding, the role, and each attached policy. A request that no grant path allows within its own ceilings is reported as `NO_APPLICABLE_GRANT`, the server's form of `no-grant`. The full list of server reasons is in [Access reviews](/docs/guides/authorization/reviews#explain-one-decision). ## Check access in your code [#check-access-in-your-code] Your server code asks for decisions at the moment it is about to do something protected. Two calls cover almost every case, and both take the caller's credential (the request `headers` or a `token`) alongside the tenant, action, and resource: * `iam.require` is the guard. It throws `IamError` with code `ACCESS_DENIED` (403) unless the request is allowed, so you call it on the line before the protected operation and let the error become a 403 response. * `iam.authorize` returns the decision instead of throwing. Call it when a denial is not an error, for example to choose between two code paths. ```ts title="app/documents/delete.ts" import { iam } from '@/lib/iam'; export async function deleteDocument(request: Request, tenantId: string, documentId: string) { // Enforce immediately before the protected operation. await iam.require({ headers: request.headers, tenantId, action: 'documents:delete', resource: { type: 'document', id: documentId }, }); await db.documents.delete(documentId); } ``` ```ts title="A decision you can branch on" const decision = await iam.authorize({ token, tenantId, action: 'documents:share', resource: { type: 'document', id: documentId }, }); // { allowed: true, reason: 'allowed', matched: [] } // { allowed: false, reason: 'ACCESS_DENIED', matched: [] } ``` Public authorization responses omit matched statements and report every denial as `ACCESS_DENIED`; the administrator-only [simulation API](/docs/guides/authorization/reviews) explains why. Denied decisions and root overrides are written to the audit log. Menus and lists need many decisions at once, and two calls exist so you do not make fifty round trips: * `iam.authorizeMany` evaluates up to fifty checks for one tenant in a single transaction. Use it to decide which buttons and menu entries to show. * `iam.listAccessible` returns the registered resources of one managed type that the caller may perform an action on, with paging and a `total`. Use it to build a list page without knowing resource IDs in advance. The browser client exposes the same three calls as `client.authorize`, `client.authorizeMany`, and `client.listAccessible`, and the framework packages wrap them in hooks such as `useAuthorize` and `Can`. See [Batches and reverse queries](/docs/guides/authorization/queries). > **Client checks are advisory.** Decisions returned to a browser are advisory: they only decide what to render. The server must call `iam.require` (or `authorize`) immediately before performing the protected operation, every time. ## Rules that always hold [#rules-that-always-hold] These guarantees hold whatever your roles and policies say, so you can rely on them when you design access: * **Deny wins.** An explicit deny overrides every allow, whichever role it came from. * **Boundaries never grant.** A tenant, principal, authority, trust, or session ceiling can only remove access. Principal and tenant ceilings are platform-controlled. * **No implicit inheritance across tenants.** Membership in a parent tenant grants nothing in its descendants. * **Delegation only narrows.** Every grant stays bounded by the authority chain it was issued under, and there is no arbitrary policy-containment solver: ceilings are enforced when a request is evaluated. * **"View as" never exceeds the administrator.** An impersonation session is allowed only what both the member and the impersonating administrator may do. * **Role sessions carry only the role.** With role assumption, the assumed role's policies replace the source identity's application permissions; the target's boundaries and the session policy still apply. ## In this section [#in-this-section] - [Permission catalog](/docs/guides/authorization/catalog): Built-in `iam:*` actions, product actions, resource types, managed resources, and tenant-defined catalogs. - [Roles and bindings](/docs/guides/authorization/roles): Custom roles, inheritance, bindings to people and groups, and delegated grant authorities. - [Policy documents](/docs/guides/authorization/policies): Statement fields and limits, evaluation, boundaries, variables, versions, testing, and lint. - [Conditions](/docs/guides/authorization/conditions): All 21 operators, negation and missing-key rules, and every context key the server provides. - [Relationships](/docs/guides/authorization/relationships): Relationship-based access: owners, editors, and viewers without listing IDs in policies. - [Batches and reverse queries](/docs/guides/authorization/queries): `authorizeMany`, `listAccessible`, and UI patterns built on advisory decisions. - [Access reviews](/docs/guides/authorization/reviews): Simulate decisions, ask who can act on a resource, audit effective actions, and scan for risk. - [Separation of duties](/docs/guides/authorization/separation-of-duties): Roles nobody may hold together, enforced on every operation that grants a role. - [Temporary access](/docs/guides/authorization/temporary-access): Expiring and future-dated bindings, access windows, access requests, role sessions, and API keys. # Policy documents (/docs/guides/authorization/policies) > Versioned JSON policies, from statement fields and limits to how grants and boundaries combine, policy variables, versions, testing, and lint. Roles say who does which job. Policy documents say what a job actually allows, precisely enough for a computer to decide. A policy document is a list of statements. Each statement says "allow" or "deny", names the actions it covers and the resources it applies to, and can add conditions: tests on the request, such as "the session used MFA" or "the caller owns this document". Because the format is plain JSON, policies can be stored, versioned, reviewed in pull requests, tested before they are saved, and linted for mistakes. The same format is used for everything that limits access too: boundaries, grant-authority ceilings, trust ceilings, and session policies. This document lets its holders read every document, edit and delete their own documents from an MFA session, and never modify archived ones: ```json title="A policy document" { "version": 1, "statements": [ { "sid": "ReadDocuments", "effect": "allow", "actions": ["documents:read"], "resources": ["document/*"] }, { "sid": "WriteOwnDocumentsWithMfa", "effect": "allow", "actions": ["documents:write", "documents:delete"], "resources": ["document/*"], "conditions": { "StringEquals": { "resource.ownerId": "${principal.id}" }, "Bool": { "principal.mfa": true } } }, { "sid": "NeverTouchArchived", "effect": "deny", "actions": ["documents:write", "documents:delete"], "resources": ["document/archive-*"] } ] } ``` Try this document in the playground In TypeScript, write documents with `definePolicy` from `better-iam`. It validates the document when your code loads, so a malformed policy fails at startup rather than when it is saved, and it returns a detached copy with its literal types preserved. ## Document fields [#document-fields] A document has two fields: Each statement: Documents are validated when they are stored and again before they are evaluated. Anything outside these limits is rejected with `INVALID_POLICY`: * The document and each statement are plain objects, and unknown fields are rejected. * Action and resource patterns are nonempty strings of at most 512 characters without control characters. * `conditions` is a nonempty map of known operators. Each operator lists 1 to 64 keys, each key is 1 to 128 characters (and never `__proto__`, `prototype`, or `constructor`), and each key has 1 to 64 expected values of the operator's type. String values are at most 2048 characters. * Every `${...}` must be a well-formed [policy variable](#policy-variables). Stored documents are also checked against the [permission catalog](/docs/guides/authorization/catalog#how-documents-are-validated): unknown exact actions fail with `INVALID_ACTION`, and unknown exact resource types with `INVALID_RESOURCE_TYPE`. ## Actions and resource patterns [#actions-and-resource-patterns] A statement rarely names one action on one resource. Patterns let it cover a family, such as "every document" or "every documents action". Patterns are anchored globs: `*` matches any run of characters, including none and including `/` and `:`, and `?` matches exactly one character. Nothing else is special: patterns are never regular expressions, so a `.` or `+` in a name means just that character. | Pattern | Matches | | ------------------- | ---------------------------------------------------------------------- | | `documents:read` | Exactly that action. | | `documents:*` | Every action in the `documents` namespace, including ones added later. | | `*` | Every action, or every resource. | | `document/q3-plan` | One resource. | | `document/*` | Every resource of type `document`. | | `*/report` | Resources with ID `report` of any type. | | `iam/*` | Every administrative resource of the tenant. | | `iam/task/apollo-*` | Administration of `task` resources whose IDs start with `apollo-`. | A request's resource is the string `type/id` (at most 2048 characters), inside a tenant that was resolved before evaluation. A pattern cannot reach another tenant, however it is written. ## How statements combine [#how-statements-combine] A person usually holds several roles, each with several statements, and some of them may disagree. The combining rules decide the outcome, and they are designed so that adding a restriction is always safe: a deny cannot be undone by another role's allow. A statement applies to a request when one of its action patterns matches the action, one of its resource patterns matches the resource, and all of its conditions hold. The grants are the documents a person holds through their roles; the boundaries are the ceiling documents described [below](#boundaries). The evaluator combines every applicable statement: 1. **Explicit deny.** If any applicable statement is a deny, the request is denied (`explicit-deny`), whatever else allows it. This includes deny statements inside boundaries. 2. **Grants union.** Otherwise the request needs at least one applicable allow among the grants (`no-grant` when there is none). 3. **Boundaries intersect.** Every boundary is independent, and each one must contain an applicable allow. One boundary without a match denies the request (`boundary-deny`). No boundaries at all means no restriction. 4. Only then is the request `allowed`. Boundaries constrain; they never grant access. An allow inside a boundary only lets grants through. You can run the same evaluator anywhere, which is handy in unit tests for your policies. `evaluatePolicy`, exported from `better-iam`, takes an action, a resource string, grant and boundary documents, and a context, and returns the decision. It is also what the [policy playground](/playground) runs in the browser: ```ts title="Evaluate offline" import { evaluatePolicy } from 'better-iam'; const decision = evaluatePolicy({ action: 'documents:write', resource: 'document/q3-plan', grants: [ { version: 1, statements: [{ sid: 'Write', effect: 'allow', actions: ['documents:*'], resources: ['document/*'] }] }, ], boundaries: [ { version: 1, statements: [{ effect: 'allow', actions: ['documents:*'], resources: ['*'], conditions: { Bool: { 'principal.mfa': true } } }] }, ], context: { 'principal.mfa': true }, }); // { allowed: true, reason: 'allowed', matched: ['grant:0:Write', 'boundary:0:0'] } ``` `matched` lists every applicable statement as `grant:{document}:{sid}` or `boundary:{document}:{sid}`, using the statement's position when it has no `sid`. The offline evaluator knows nothing about tenants or identities: the caller must establish trusted context and tenant scope. On the server, the context is built for you (see [Context keys](/docs/guides/authorization/conditions#context-keys)). Public authorization responses omit matched statements. The administrator-only [simulation API](/docs/guides/authorization/reviews#explain-one-decision) returns them, and `policies.test` returns them for candidate documents. ## Boundaries [#boundaries] Grants answer "what may this person do?". A boundary answers a different question: "what is the most anyone here may ever do, whatever their roles say?". It is a policy document used as a ceiling. You reach for one when the people writing roles should not be the last line of defense: a platform operator fencing off a trial tenant, an integration key that must only read, or a delegated administrator who must never grant billing access. Several kinds of boundary can apply to one request, and each one is an independent intersection: | Boundary | What it is for | Set with | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | Tenant boundary | Caps everything in a tenant and its descendants, for example a plan that excludes webhooks. | `tenants.setBoundary` (root only, `iam:boundaries:update`, recent authentication), or `boundary` on `tenants.create` | | Principal boundary | Caps one identity in one tenant, whatever roles it later receives. | `identities.setBoundary` (root only, `iam:boundaries:update`) | | Grant-authority ceiling | Caps every role, policy, and binding issued under a delegated authority or below it. | `authorities.create`; see [Grant authorities](/docs/guides/authorization/roles#grant-authorities) | | Trust ceiling | Caps role sessions started through one trust. | `trust.create({ ceiling })`; see [Role sessions](/docs/guides/authorization/temporary-access#role-sessions) | | Session policy | Caps one role session or one API key below what its identity could do. | `roles.assume({ policy })`, `credentials.create({ policy })`, or `scopes` | | Credential authority | Caps every request made with an API key at its issuing authority's ceilings, and a session exchanged from an external identity provider's token at the ceilings of whoever registered that provider. | Automatic | `tenants.setBoundary` replaces the boundary of one tenant, and `identities.setBoundary` replaces the boundary of one person or service account. Both are platform-controlled: only root administrators may call them, so tenant administrators cannot lift their own ceilings. Delegation can create a narrower child authority while retaining its parent chain, and there is no arbitrary policy-containment solver, so a ceiling is enforced when a request is evaluated rather than proved when a role is created. ```ts title="A tenant ceiling set by a root administrator" await iam.api.tenants.setBoundary(rootCredential, { tenantId, boundary: { version: 1, statements: [ { effect: 'allow', actions: ['*'], resources: ['*'] }, { effect: 'deny', actions: ['iam:webhooks:*'], resources: ['*'] }, ], }, }); ``` > **Deny inside a boundary.** A deny statement in a boundary denies with `explicit-deny`, like a deny in a grant. A boundary that lists only denies, though, has no allow for anything and therefore blocks every request. Pair its denies with a broad allow, as above. See a boundary override an administrator role The grant allows every administrative action, and the boundary still denies creating a webhook. Change the action to `iam:roles:create` and the same administrator is allowed, because the boundary's broad allow covers it. ## Policy variables [#policy-variables] "People may edit their own documents" is one rule, but without variables it would need one statement per person. A policy variable fills in a value from the request instead. A resource pattern or a string condition value may reference a trusted context key as `${key}`, and the value is substituted before matching, so one statement can describe "the caller's own" resources: ```json title="Owner-only access" { "effect": "allow", "actions": ["documents:*"], "resources": ["document/*"], "conditions": { "StringEquals": { "resource.ownerId": "${principal.id}" } } } ``` ```json title="A home folder per person" { "effect": "allow", "actions": ["folders:read"], "resources": ["folder/home-${principal.id}"] } ``` The rules: * **Where.** Variables work in resource patterns after the resource type (the part before the first `/`), in the values of the `String*` operators, and in the values listed for `ArrayContains` and `ArrayContainsAll`. They may not appear in action names or in the resource type segment; either is rejected when the document is stored. * **Syntax.** A key starts with a letter or underscore and continues with letters, digits, `_`, `.`, `:`, or `-`, up to 128 characters. A malformed reference such as `${principal.id` is rejected when the document is stored. * **Literal substitution.** The substituted value always matches literally: a value containing `*` or `?` cannot widen a pattern. * **Unresolved never matches.** A variable whose key is absent from the context, or holds a list, does not resolve, and the pattern or value that uses it never matches. Strings, numbers, and booleans resolve. * **Any trusted key.** Any key from `resolveContext` can be referenced, so `${principal.department}` works once the application supplies it (or it is a declared identity attribute). > **Unresolved variables and negated operators.** A negated operator inverts a comparison that failed, so `StringNotEquals` against an unresolved variable evaluates **true**. Add `Exists: { "principal.department": true }` to such statements. Likewise, `StringLikeIgnoreCase` lowercases its pattern, variable names included, so a variable whose key has capital letters never resolves there; use `StringLike` or a lowercase key. The linter reports both. ## Manage stored policies [#manage-stored-policies] An inline role document is fine for a role's own permissions. When several roles share the same rules, store them once as a policy and attach it to each role, so a fix reaches all of them. Stored policies are also versioned: each save keeps the previous document, so you can see what changed and roll back a bad edit without rebuilding the old document by hand. ```ts title="Create, update, restore" const policy = await iam.api.policies.create(credential, { tenantId, name: 'Document editors', document: editorsDocument, }); // Updates require the version you read (optimistic concurrency). const updated = await iam.api.policies.update(credential, { tenantId, policyId: policy.id, version: policy.version, document: revisedDocument, }); const history = await iam.api.policies.listVersions(credential, { tenantId, policyId: policy.id }); await iam.api.policies.restoreVersion(credential, { tenantId, policyId: policy.id, version: 1 }); ``` * `policies.create` stores a new policy at version 1. It requires `iam:policies:create` and records the caller's grant authority. The tenant's `policies` plan limit, when set, applies (`LIMIT_EXCEEDED`). * `policies.update` saves a new document, name, or description as the next version. It requires `iam:policies:update` and the `version` you read, so two administrators editing at once cannot silently overwrite each other: a stale version fails with `VERSION_CONFLICT` (409). An update with nothing to change fails with `INVALID_INPUT`. * `policies.listVersions` returns the policy's history, every version oldest first, including the current one. * `policies.restoreVersion` rolls back to an earlier document by saving it as a new version, so history is never rewritten. It re-validates the old document against today's catalog: a document that names an action removed since then fails with `INVALID_ACTION`. Restoring the current version fails with `INVALID_INPUT`. * `policies.delete` removes a policy no role uses any more. It is refused while any role attaches the policy (`RESOURCE_IN_USE`). * `policies.get` returns one policy and `policies.list` every policy of the tenant; both require `iam:policies:read`. Only the holder of the authority that created a policy, or root, can edit or delete it, and the protected *Owner* policy cannot be changed at all (`PROTECTED_RESOURCE`). Attach policies to roles with `policyIds` on [`roles.create` and `roles.update`](/docs/guides/authorization/roles#create-a-role). ## Test a document before saving it [#test-a-document-before-saving-it] A policy that is wrong in production either locks people out or lets them in. Two tools catch mistakes before a document is saved: a test that evaluates it against sample requests, and a linter that reads it for common errors. `policies.test` evaluates a candidate document that is not stored against an action, a resource string, and a context you supply, and returns the full decision with the matched statements. It requires `iam:policies:simulate` and validates the document against the catalog first. It is meant for policy editors and CI: it creates no session and grants nothing. ```ts const decision = await iam.api.policies.test(credential, { tenantId, document: candidate, action: 'documents:write', resource: 'document/alice-notes', context: { 'principal.id': 'alice', 'principal.mfa': true, 'resource.ownerId': 'alice' }, }); ``` The context holds at most 200 keys. `principal.tenantId` and `resource.tenantId` default to the tenant and `request.time` to the current time; your context may override them. To see how a stored configuration treats a real person, use [`policies.simulate`](/docs/guides/authorization/reviews#explain-one-decision) instead. ### Lint [#lint] Some policies are valid but almost certainly not what their author meant: a deny that can never apply, an allow that a deny always cancels, a condition on a key the server never sets. The linter finds these. `analysis.lintPolicy({ tenantId, document })` or `analysis.lintPolicy({ tenantId, policyId })` checks a candidate or stored document the way the server will evaluate it. It requires `iam:policies:read`. It first validates the document against the catalog, returning `valid: false` with the `error` (such as `INVALID_ACTION`) when that fails, and otherwise returns `warnings` sorted by statement. Pass `contextKeys` to declare keys your `resolveContext` supplies, so the linter does not report them as unknown. A `warning` means the document likely does not do what it says; `info` is worth a look but often intended. | Code | Severity | What it reports | | --------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `unrestricted-admin` | warning | An unconditional allow of `*` or `iam:*` on `*`: every holder is a full administrator. | | `service-wildcard` | info | An unconditional service-wide wildcard such as `documents:*`, which also grants actions added later. | | `unknown-context-key` | warning | A condition key or variable the server never sets (for example `request.ip`) and the configuration does not declare. | | `optional-key-deny` | warning | A deny conditioned on a key that can be missing (`principal.authMethod`, `principal.mfaTime`, `request.sourceIp`, session tags, identity attributes, resource attributes, application keys), which silently never applies without an `Exists` guard. | | `negated-variable` | warning | A negated string operator whose variable may not resolve, which then evaluates true. | | `ignorecase-variable` | warning | `StringLikeIgnoreCase` with a variable whose key has capitals, which never resolves. | | `array-key-string-operator` | warning | A string operator on `principal.groups`, `principal.roles`, or a relation list, where only `ArrayContains` works. | | `array-variable` | warning | A variable that names a list, which never resolves. | | `type-mismatch` | warning | An operator that can never match the key's type, such as `NumericEquals` on a boolean. | | `always-true-condition` | info | `Bool` or `Exists` listing both `true` and `false`. | | `duplicate-statement` | info | A statement that repeats an earlier one. | | `shadowed-allow` | warning | An allow whose every action and resource an unconditional deny also covers, so it never grants anything. | | `deny-only` | info | A document without allow statements: it grants nothing, though its denies still restrict every holder. | | `shadowed-allow-skipped` | info | The shadowing check ran out of its work budget, so adversarial documents stay fast. | `lintPolicy` is also exported from `better-iam/server` for offline use, for example in CI. The offline form checks the document's structure but not your tenant's catalog; pass `identityAttributes`, `resourceAttributes`, and `contextKeys` as its second argument to describe your deployment. ```ts title="scripts/lint-policies.ts" import { lintPolicy } from 'better-iam/server'; const result = lintPolicy(document, { identityAttributes: { department: 'string' }, contextKeys: ['app.plan'] }); for (const warning of result.warnings) console.log(warning.severity, warning.code, warning.message); ``` The access analysis (`analysis.findings`) reports stored policies and inline role documents with lint warnings as `policy-lint` findings. See [Access reviews](/docs/guides/authorization/reviews#scan-for-risky-configuration). ## Next steps [#next-steps] - [Conditions](/docs/guides/authorization/conditions): Every operator and context key. - [Policy playground](/playground): Evaluate grants and boundaries in your browser. - [Policies API](/docs/reference/api/policies): Signatures for create, update, test, simulate, and reviews. # Batches and reverse queries (/docs/guides/authorization/queries) > Check many actions at once with authorizeMany, list the resources a caller may act on with listAccessible, and render UI from advisory decisions. Rendering a page usually needs many decisions: which buttons to show on a document, which menu entries to offer, which projects to list. Asking one at a time is slow, and listing resources one ID at a time is impossible when you do not know the IDs yet. Two calls cover these cases: * `iam.authorizeMany` answers up to fifty checks in one round trip. * `iam.listAccessible` answers "which resources of this type may I act on?" for managed resource types. Asking which resources a person can reach, rather than whether they can reach one, is called a reverse query. Both are advisory: like `iam.authorize`, they tell you what the caller could do right now, to decide what to render. They do not protect anything by themselves, because access can change between rendering a button and clicking it. The server must still call `iam.require` (or `authorize`) immediately before performing each protected operation. ## Check many actions at once [#check-many-actions-at-once] A document toolbar might need to know whether the person may edit, share, and delete. Three separate `authorize` calls would work, but each is a round trip and each could see a slightly different configuration. `iam.authorizeMany({ ...credential, tenantId, checks })` evaluates 1 to 50 checks for one tenant in a single transaction, so every answer reflects the same view of the configuration. Each check names an action and a resource. It is intended for rendering menus and lists. ```ts title="Server: which document actions to offer" const resource = { type: 'document', id: documentId }; const { results } = await iam.authorizeMany({ headers: request.headers, tenantId, checks: [ { action: 'documents:write', resource }, { action: 'documents:share', resource }, { action: 'documents:delete', resource }, ], }); // results: [{ action, resource, allowed, reason }, ...] in the order of the checks const can = Object.fromEntries(results.map((result) => [result.action, result.allowed])); ``` Each check is evaluated and recorded exactly like `iam.authorize`: denials are audited and a denied check reports `reason: 'ACCESS_DENIED'`. Fewer than one or more than fifty checks fail with `INVALID_INPUT`. Use `authorizeMany` for application-owned resource types too: they are not enumerable, so check the IDs your product already loaded. ## List accessible resources [#list-accessible-resources] A "My projects" page cannot check projects one by one, because it does not know which project IDs to check. It needs the opposite question: of all projects, which may this person open? Conditions, group bindings, and shared folders all affect the answer, so filtering in your own database would duplicate your policies. `iam.listAccessible({ ...credential, tenantId, action, type })` answers it. It returns the registered resources of one managed type that the caller may perform the action on: ```ts title="Server: the projects a person can open" const { resources, total } = await iam.listAccessible({ headers: request.headers, tenantId, action: 'projects:read', type: 'project', limit: 25, offset: 0, }); // resources: [{ type: 'project', resourceId: 'apollo', attributes, ownerId, parentId, ... }] // total: how many projects are accessible in all ``` * `limit` (default 100, at most 1000) and `offset` page over the accessible set, ordered by type and ID, and `total` counts all of it. * Everything a normal decision uses applies: conditions on attributes and owners, [relationships](/docs/guides/authorization/relationships), boundaries, access windows, and activations. * Only managed types can be listed. An application-owned type fails with `INVALID_RESOURCE_TYPE`, and an action outside the catalog with `INVALID_ACTION`. * In an impersonation session, only resources both the member and the impersonating administrator can reach are returned. The result is advisory. List pages no longer need to know resource IDs up front, but opening a project still goes through `iam.require`. ### How batch evaluation works [#how-batch-evaluation-works] A single decision loads the caller's state from storage: root override, tenant status, boundaries (ceilings on what the caller can reach), grants through every binding and group, the evaluation context, and the caller's relationship tuples. `listAccessible` prepares that state once and evaluates every registration of the type against it, so grants and boundaries are loaded once no matter how many resources the type has. The review calls `policies.effectiveActions` and `policies.whoCan` work the same way, preparing once per identity. `authorizeMany` evaluates each check in turn inside its one transaction. ## Register resources in bulk [#register-resources-in-bulk] Lists only show what is registered, so adopting `listAccessible` for an existing product usually starts with a backfill. `resources.registerMany` registers up to 100 managed resources in one transaction, which also suits imports: ```ts await iam.api.resources.registerMany(credential, { tenantId, resources: [ { type: 'project', id: 'apollo', ownerId: alice.id }, { type: 'project', id: 'gemini', attributes: { archived: true } }, { type: 'task', id: 'apollo-1', parentId: 'apollo' }, ], }); ``` Every item is authorized as `iam:resources:create` on `iam/{type}/{id}` before anything is written. A denied item rejects the whole batch with `ACCESS_DENIED` and records only its denial. Items are registered in order, so a parent can precede its children in the same batch. ## Render UI from decisions [#render-ui-from-decisions] Hiding a button a person cannot use is kinder than letting them click and see an error. The browser client exposes the same calls as `client.authorize`, `client.authorizeMany`, and `client.listAccessible`, with the session cookie as the credential, and the framework packages wrap them in hooks that re-run when the signed-in person changes: **React:** ```tsx title="components/document-toolbar.tsx" import { Can, useAccessible, useAuthorize } from '@better-iam/react'; export function DocumentToolbar({ tenantId, documentId }: { tenantId: string; documentId: string }) { const resource = { type: 'document', id: documentId }; const { status, allowed } = useAuthorize({ tenantId, checks: [ { action: 'documents:write', resource }, { action: 'documents:delete', resource }, ], }); if (status !== 'ready') return null; return (
{allowed('documents:write', resource) && } {allowed('documents:delete', resource) && }
); } export function ProjectList({ tenantId }: { tenantId: string }) { const { status, resources, total } = useAccessible({ tenantId, action: 'projects:read', type: 'project', limit: 20 }); if (status !== 'ready') return

Loading…

; return (
    {resources.map((project) => (
  • {project.resourceId}
  • ))}
); } ``` **Vue:** ```vue title="components/DocumentToolbar.vue" ``` The helpers in the example: * `useAuthorize` sends all its checks in one `authorizeMany` call and re-runs when the checks or the signed-in identity change. Its `allowed(action, resource)` is false until results arrive, and every check reads as denied when nobody is signed in. * `Can` (React) and `useCan` (Vue) wrap a single check. `Can` renders its children when the check is allowed, its `fallback` when it is not, and its `loading` content while it waits. Without a `resource`, the check is on the tenant itself. * `useAccessible` wraps `listAccessible` the same way and returns the page of `resources` and the `total`, for list pages. SvelteKit, Next.js, and the other integrations offer the same helpers; see [Frameworks](/docs/frameworks). ## Choose the right call [#choose-the-right-call] | You need to | Use | | ------------------------------------------------ | ----------------------------------------------------------------------------------------- | | Enforce access before a mutation or a read | `iam.require` | | Branch on one decision on the server | `iam.authorize` | | Show or hide several buttons or menu entries | `authorizeMany`, `useAuthorize`, `Can`, `useCan` | | List the managed resources a person may open | `listAccessible`, `useAccessible` | | Filter application-owned rows you already loaded | `authorizeMany` with their IDs, up to fifty per call | | Tell a refused person how they could get access | `accessPaths.find`; see [Self-service access paths](/docs/guides/governance/access-paths) | | Show an administrator who can reach something | `policies.whoCan`; see [Access reviews](/docs/guides/authorization/reviews) | ## Next steps [#next-steps] - [React](/docs/frameworks/react): `useAuthorize`, `Can`, and `useAccessible` in detail. - [Vue](/docs/frameworks/vue): `useAuthorize`, `useCan`, and `useAccessible` for Vue and Nuxt. - [Typed client](/docs/frameworks/client): `client.authorizeMany` and `client.listAccessible` from any browser code. # Relationships (/docs/guides/authorization/relationships) > Relationship-based access control. Declare relations on resource types, record who holds them, and write policies that read resource.relations. Roles work well for "all editors may edit all documents". They work badly for sharing: "Alice may edit this one folder, and the design team may view it". Writing a policy per folder does not scale, and people share things all day. Relationship-based access control (ReBAC) handles this case. A relationship (or tuple) records that a person or a group stands in a named relation to one resource: *alice is an `owner` of `folder/plans`*, *the design group are `viewer`s of `folder/plans`*. A role then says "viewers may read" once, and sharing a folder becomes a matter of adding a tuple rather than editing a policy. Policies see the relations the principal (the caller) holds as `resource.relations`. With the tuples above, a policy evaluated for a member of the design group on `folder/plans` sees `resource.relations` as `["viewer"]`, and on `file/roadmap` sees `resource.parentRelations` as `["viewer"]`. ## Declare relations [#declare-relations] First decide which relations make sense for each type of thing, such as `viewer`, `editor`, and `owner` for folders. Declaring them up front means a typo like `veiwer` is refused instead of silently granting nothing. A resource type declares the relations it supports in `permissions.resourceTypes`, in a plugin's `resourceTypes`, or, for tenant-defined types, on `resourceTypes.register` and `resourceTypes.update`: ```ts title="lib/iam.ts" permissions: { resourceTypes: { folder: { managed: true, actions: ['folders:read', 'folders:share'], relations: ['viewer', 'editor', 'owner'] }, file: { managed: true, parent: 'folder', actions: ['files:read'], relations: ['viewer'] }, }, }, ``` Relation names are lowercase identifiers (a letter, then letters, digits, `_`, or `-`), at most 32 per type. Tuples naming an undeclared relation are rejected, and a relation someone still holds cannot be dropped from its type (`RESOURCE_IN_USE`). ## Record relationships [#record-relationships] Your product records a tuple whenever someone shares something or creates something they should own. Call `iam.api.relationships.create` from the code path that handles the share: ```ts await iam.api.relationships.create(credential, { tenantId, type: 'folder', id: 'plans', relation: 'viewer', subjectType: 'group', // or 'identity' subjectId: design.id, expiresAt: Date.now() + 30 * 86_400_000, // optional }); ``` * It requires `iam:relationships:create` on `iam/{type}/{id}`, so who may share what is itself a policy decision. * The type must be known (`INVALID_RESOURCE_TYPE`) and the relation declared for it (`INVALID_INPUT`). Managed resources must be registered (`NOT_FOUND`); application-owned resources are accepted as named. * The subject is an identity of the tenant (not deleted) or a group of the tenant. * `expiresAt` (epoch milliseconds, in the future, within ten years) makes the tuple temporary. An expired tuple grants nothing. * Creating a tuple that already exists updates it in place, replacing its expiry (or clearing it when you omit `expiresAt`). To show a "shared with" panel, call `relationships.list`. It returns tuples filtered by `type`, `id`, `relation`, `subjectType`, and `subjectId`, newest first, and omits expired tuples unless you pass `includeExpired: true`. It requires `iam:relationships:read` on `iam/{type}/{id}` when you filter by both type and ID, on `iam/{type}/*` when you filter by type, and on `iam/*` otherwise. To stop sharing, call `relationships.delete({ tenantId, relationshipId })`, which removes one tuple and requires `iam:relationships:delete` on the tuple's resource. Tuples are removed together with their identity, group, or managed resource. Role sessions hold no relations, even when the source identity does. ## Use relations in policies [#use-relations-in-policies] A tuple on its own grants nothing; a policy has to say what each relation allows. During evaluation, the principal's live relations on the evaluated resource, held directly or through group membership, appear as `resource.relations`, a sorted list of relation names. Those held on the resource's registered parent appear as `resource.parentRelations`. Test them with the `ArrayContains` condition, which holds when the principal has any of the listed relations: ```ts title="A role that turns relations into permissions" await iam.api.roles.create(credential, { tenantId, name: 'Sharing', document: { version: 1, statements: [ { effect: 'allow', actions: ['folders:read'], resources: ['folder/*'], conditions: { ArrayContains: { 'resource.relations': ['viewer', 'editor', 'owner'] } }, }, { effect: 'allow', actions: ['files:read'], resources: ['file/*'], conditions: { ArrayContains: { 'resource.parentRelations': ['viewer', 'editor', 'owner'] } }, }, ], }, }); ``` Bind a role like this to a group everyone belongs to. The role grants nothing by itself; the tuples decide which folders each person reaches. Try the sharing role in the playground In the playground, the context stands in for the tuples: `resource.parentRelations` is what the server would derive from a `viewer` tuple on the file's folder. Empty the list and the read is no longer allowed. * `resource.parentRelations` looks one level up, to the registered parent only. Relations on a grandparent are not consulted, so record tuples at the level your policies read. * Both keys are lists. `StringEquals` never matches them; use `ArrayContains` (any of) or `ArrayContainsAll` (all of). The linter reports the mistake as `array-key-string-operator`. * A principal without relations sees an empty list, so `ArrayContains` is false and the statement does not apply. ## Let owners share [#let-owners-share] Sharing is only self-service if the people who own things can share them without being administrators. Recording a tuple is itself an administrative action (`iam:relationships:create`), so the trick is a policy that allows it only to owners of the resource. For administrative actions on `iam/{type}/{id}` that name a registered managed resource, `resource.relations`, `resource.parentRelations`, `resource.ownerId`, `resource.parentId`, and the registered attributes describe that resource. That is what lets an `owner` share a folder without holding a tenant-wide administrative role: ```json title="Owners may share their own folders" { "effect": "allow", "actions": ["iam:relationships:create", "iam:relationships:delete"], "resources": ["iam/folder/*"], "conditions": { "ArrayContains": { "resource.relations": ["owner"] } } } ``` ```ts title="An owner shares a folder" // The server checks the caller's owner relation on iam/folder/plans. await iam.api.relationships.create(ownerCredential, { tenantId, type: 'folder', id: 'plans', relation: 'viewer', subjectType: 'group', subjectId: designTeam.id, }); ``` The same works with registered ownership instead of a relation, using a [policy variable](/docs/guides/authorization/policies#policy-variables): `{ "StringEquals": { "resource.ownerId": "${principal.id}" } }` on `iam/folder/*`. ## Lists and reviews [#lists-and-reviews] Shared resources should show up in lists, and reviewers should see who reaches a resource through sharing. Reverse queries see relationships too: [`listAccessible`](/docs/guides/authorization/queries#list-accessible-resources) returns the folders a person can read through their tuples, and [`policies.whoCan`](/docs/guides/authorization/reviews#who-can-act-on-a-resource) lists everyone who reaches a folder through one. ```ts const { resources, total } = await iam.listAccessible({ token, tenantId, action: 'folders:read', type: 'folder' }); ``` ## Next steps [#next-steps] - [Sharing recipes](/docs/guides/recipes/sharing-and-reviews): Copy-ready sharing and review patterns. - [Relationships API](/docs/reference/api/relationships): Signatures for create, list, and delete. - [Managed resources](/docs/guides/authorization/catalog#managed-types): Register the resources relations point at. # Access reviews (/docs/guides/authorization/reviews) > Explain and review access without granting it. Simulate a decision, list who can act on a resource, see a person's effective actions, and scan for risk. Sooner or later someone asks: why was Alice refused? Who can delete the payroll workspace? What can this contractor actually do? Answering by reading roles and policies is slow and error-prone, because groups, inheritance, conditions, boundaries, and time windows all interact. The review calls answer these questions by running the real evaluator instead, against the tenant's live configuration. Three administrator-only reads, all under `iam:policies:simulate`, explain access without creating sessions or granting anything: | Question | Call | | --------------------------------------------- | ---------------------------------------------------- | | Why is this person allowed or refused? | [`policies.simulate`](#explain-one-decision) | | Who can perform this action on this resource? | [`policies.whoCan`](#who-can-act-on-a-resource) | | What can this person do on this resource? | [`policies.effectiveActions`](#what-can-a-person-do) | Each evaluates the identity in a synthetic session: a pretend sign-in that is never issued and cannot be used. With `assumeMfa: true` it simulates an MFA-verified session; otherwise conditions on `principal.mfa` see `false`. Results are advisory and never enforcement. ## Explain one decision [#explain-one-decision] When a person reports "access denied" or an auditor asks why someone was allowed, you need the evaluator's reasoning, not just yes or no. Public authorization responses deliberately hide it: they omit matched statements and report every denial as `ACCESS_DENIED`, so callers cannot probe your policies. `policies.simulate({ tenantId, identityId, action, resource, assumeMfa? })` returns the full decision for one check as that person, including the matched statements and the precise reason. It requires `iam:policies:simulate` on the identity (`iam/{identityId}`). ```ts const decision = await iam.api.policies.simulate(credential, { tenantId, identityId: alice.id, action: 'documents:delete', resource: { type: 'document', id: 'q3-plan' }, assumeMfa: true, }); // { allowed: false, reason: 'explicit-deny', matched: [...] } ``` `matched` lists the statements that applied, by their `sid` (or position), so you can find the deny or the allow responsible. Give statements a `sid` to make these explanations readable. The reasons a server decision can carry: | Reason | What it means | Where to look | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | `allowed` | A grant allowed it, no deny matched, and every boundary allowed it. | | | `ROOT_OVERRIDE` | A root administrator in an MFA-verified session; policies are not consulted. | | | `explicit-deny` | A deny statement matched, in one of the person's roles or in a boundary. | The `matched` list names the statement. | | `boundary-deny` | A tenant, principal, session, or credential boundary does not allow the action. | [Boundaries](/docs/guides/authorization/policies#boundaries) | | `NO_APPLICABLE_GRANT` | No role allowed it within its authority ceilings: no role with the action, a condition that did not hold, or a ceiling that cut it off. | [`effectiveActions`](#what-can-a-person-do), the person's bindings | | `UNKNOWN_ACTION` | The action is not in the tenant's catalog. | [Permission catalog](/docs/guides/authorization/catalog) | | `TENANT_INACTIVE` | The tenant or one of its ancestors is suspended or not yet active. | | | `TENANT_MISMATCH` | The session belongs to another tenant. | | | `CREDENTIAL_AUTHORITY_REVOKED` | The API key's issuing authority was revoked, or, for a session exchanged from an external identity provider's token, the authority of whoever registered that provider. | [Grant authorities](/docs/guides/authorization/roles#grant-authorities) | To try a document that is not stored yet, use [`policies.test`](/docs/guides/authorization/policies#test-a-document-before-saving-it) instead: it evaluates a candidate document against a context you supply. ## Who can act on a resource [#who-can-act-on-a-resource] Before you delete a shared folder, hand an auditor a list of who can approve payments, or check that only the finance team can export the ledger, you need the inverse question: not "may Alice do this?" but "who may?". `policies.whoCan` lists every active identity that could perform an action on a resource, with the decision reason, plus a `total`: ```ts const { identities, total } = await iam.api.policies.whoCan(credential, { tenantId, action: 'payments:approve', resource: { type: 'ledger', id: 'main' }, assumeMfa: true, limit: 50, }); // identities: [{ identityId, name, email, kind, reason }], total: 12 ``` * It requires `iam:policies:simulate` on the tenant. * Root administrators are not listed because their override applies everywhere. * Access through groups, inheritance, relationships, activations, and access windows all counts. * The resource is resolved once, but grants are loaded per identity, so the call scales with the tenant's directory size. It is meant for review screens, not per-request checks. ## What can a person do [#what-can-a-person-do] When a contractor joins a project, or before an access-certification decision, the useful view is "everything this person can do here", not one action at a time. `policies.effectiveActions({ tenantId, identityId, resource, actions?, assumeMfa? })` evaluates every catalog action, or the list you pass (at most 200), against one resource for one identity. It loads the identity's grants once and returns `allowed` (the sorted action names) and `results` with a reason per action: ```ts const { allowed, results } = await iam.api.policies.effectiveActions(credential, { tenantId, identityId: contractor.id, resource: { type: 'folder', id: 'plans' }, }); // allowed: ['files:read', 'folders:read'] // results: [{ action: 'folders:share', allowed: false, reason: 'NO_APPLICABLE_GRANT' }, ...] ``` It requires `iam:policies:simulate` on the identity. More than 200 actions fail with `INVALID_INPUT`, and an action outside the catalog with `INVALID_ACTION`. Platform resources (`iam/...`) are accepted, so you can also ask which administrative actions someone holds on a role or group. ## What simulations take into account [#what-simulations-take-into-account] Simulations run the ordinary evaluator against the live configuration. Everything that shapes a real decision counts: bindings direct and through groups, role inheritance, conditions, relationships, boundaries, authority ceilings, live activations of eligible bindings, and access windows at the moment of evaluation. The synthetic session differs from a real one in a few ways: * `principal.mfa` is `false` unless you pass `assumeMfa: true`. * `principal.sessionKind` is `user` for people and `api-key` for service accounts, and `principal.authMethod` is absent, so conditions on the sign-in method do not hold. * `principal.mfaTime` and `request.sourceIp` are always absent, even with `assumeMfa: true`, so conditions that demand recent MFA or a particular network do not hold. `principal.authTime` is the moment of evaluation, and the session carries no tags. * The session is never impersonated, and no session policy or API key scope applies. ## Scan for risky configuration [#scan-for-risky-configuration] Individual reviews answer questions you already have. A scan finds the ones you did not think to ask: an admin policy without conditions, an owner without MFA, an API key nobody has used in months. `analysis.findings({ tenantId, dormantDays?, includeSuppressed? })` scans the tenant's configuration and returns `summary` counts and `findings` ordered by severity. It requires `iam:analysis:read`. Each finding carries a deterministic `id`, a `kind`, a `severity`, a `title`, a `detail`, and the `subject` it is about. ```ts const { summary, findings } = await iam.api.analysis.findings(credential, { tenantId, dormantDays: 60 }); // summary: { high: 1, medium: 3, low: 5, suppressed: 0 } ``` | Kind | Severity | What it reports | | ---------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `unrestricted-admin-policy` | high | Policies or inline role documents that allow `*` or `iam:*` on `*` without conditions. | | `admin-without-mfa` | high | Owners or holders of such roles without an authenticator or passkey, while the tenant does not require MFA. | | `separation-of-duties` | high | People holding roles a separation-of-duties [rule](/docs/guides/authorization/separation-of-duties) forbids together. | | `broad-action-wildcard` | medium | Unconditional service-wide wildcards such as `documents:*`. | | `service-account-admin` | medium | Service accounts with full administration. | | `dormant-access` | medium | People who hold bindings or ownership but have not signed in for `dormantDays` (default 90). | | `stale-api-key` | medium | API keys unused for `dormantDays`, or never used. | | `trust-without-mfa` | medium | Role trusts that do not require MFA. | | `standing-privileged-access` | medium | A person with a direct, permanent binding (not eligible, no end) to a role that grants full administration, which just-in-time eligibility or an end date would narrow. | | `manager-cycle` | medium | Reporting lines that loop. | | `unattached-policy` | low | Policies attached to no role. | | `unused-role`, `empty-role` | low | Roles bound to nobody, or granting nothing. | | `empty-group-with-access` | low | Groups that hold bindings but have no members. | | `unused-eligible-binding` | low | Eligible bindings nobody has activated within `dormantDays`. Only allowed activation and approval events count, so a member cannot hide it through `authorize`. | | `orphaned-manager` | low | People whose manager is missing, deleted, or disabled. | | `policy-lint` | low | Stored policies or inline role documents with [lint](/docs/guides/authorization/policies#lint) warnings. | System owner policies and protected roles are not reported as misconfiguration. The scan reads the whole tenant, so it is meant for review screens and scheduled reports rather than request paths. Some findings are accepted risks, such as a break-glass account that is meant to be dormant. Because finding IDs are deterministic, you can record that decision once. `analysis.suppress({ tenantId, findingId, reason })` hides a finding from future results with a reason kept for reviewers (at most 500 characters), and `analysis.unsuppress({ tenantId, findingId })` shows it again. Both require `iam:analysis:update`. Pass `includeSuppressed: true` to `analysis.findings` to list suppressed findings too. ## Guardrails that must always hold [#guardrails-that-must-always-hold] Reviews look at access as it is today. Access invariants go further: they are lines that must hold whatever roles and policies say, such as "contractors can never delete the payroll workspace" or "the on-call group can always restart production". They are checked continuously and, if you choose, enforced on every change. ```ts await iam.api.invariants.create(credential, { tenantId, name: 'Contractors never approve payments', subject: { attribute: { name: 'contractor', value: true } }, action: 'payments:approve', resource: { type: 'ledger', id: 'main' }, expect: 'deny', mode: 'enforce', }); ``` * `invariants.create` stores one (`iam:invariants:manage`). The `subject` is exactly one of `{ identityId }`, `{ groupId }` (its live members), `{ attribute: { name, value } }` (active identities whose declared attribute equals the value), or `{ everyone: true }`. `expect: 'deny'` means nobody in the subject may be allowed; `expect: 'allow'` means everyone in it must be. The resource must resolve. * `mode: 'monitor'` (the default) only reports; `'enforce'` also guards changes. `assumeMfa` (default true) evaluates people as MFA-verified, the most they can reach. * `invariants.run({ tenantId, invariantId? })` (`iam:invariants:read`) evaluates them now with the ordinary evaluator and returns, per invariant, `passed`, the `violations` (person and decision reason), and an `error` when it can no longer be evaluated. At most 500 people are evaluated per invariant in a run. * `invariants.list` returns the tenant's invariants, `invariants.update` changes one (for example to switch it from `monitor` to `enforce`), and `invariants.delete` removes one. Names are unique per tenant, and a tenant holds at most 100. An enforced invariant is evaluated, for every subject, before and after each operation that can change access: role and policy edits, bindings, activations, group membership, identity changes, package assignments, configuration apply, relationship and resource changes, boundary and authority changes, and more. An operation that newly breaks one, or makes it impossible to evaluate, is refused with `INVARIANT_VIOLATION` (409) and rolled back. Violations that already existed do not block unrelated work, so you can switch an invariant to `enforce` while it is still broken. Scheduled jobs, inbound SCIM provisioning, and members accepting agreements run outside that envelope and are not guarded; schedule `iam.checkInvariants()` (CLI `better-iam monitor-invariants`) to be told when a monitored invariant breaks, and gate CI with `better-iam check-invariants --tenant ID --fail-on-broken`. The full lifecycle, alerts, and the change impact preview are covered in [Change safety](/docs/guides/governance/change-safety). ## Related reviews [#related-reviews] - [Certifications](/docs/guides/governance/certifications): Campaigns in which reviewers keep or revoke each binding, with a recorded decision. - [Usage and role mining](/docs/guides/governance/usage-and-mining): Find unused access, redundant bindings, and peer outliers. - [Change safety](/docs/guides/governance/change-safety): Preview who gains and loses access before a change, and enforce invariants. - [Access report](/docs/guides/privileged-access/access-report): What ends soon, live activations, pending requests, and unused keys. # Roles and bindings (/docs/guides/authorization/roles) > Custom roles built from permission lists or policy documents, role inheritance, bindings to people and groups, and delegated grant authorities. Listing permissions person by person does not scale: when a new editor joins, someone has to remember every action an editor needs, and when the editor job changes, every editor has to be updated. Roles solve this. A role is a named set of permissions for a job function, such as *Editor* or *Approver*, defined once and given to everyone who does that job. A binding is what gives a role to someone: it links one role to one identity (a person or service account) or to one group. A person receives every role bound to them directly or through a group they belong to. Change the role, and everyone who holds it changes with it. Roles are built from [policy documents](/docs/guides/authorization/policies), so a role can be a plain list of actions or carry conditions. Every role, policy, and binding is also issued under a grant authority: the delegated right to hand out access, with a ceiling on what it may ever grant. That is how you let a team lead manage their own team's roles without making them an administrator. ## Create a role [#create-a-role] Create one role per job function in your product. `roles.create` (requires `iam:roles:create`) stores a role and returns it, with its ID for bindings. A role grants the union of its attached policies and its optional inline document, and you describe its permissions in one of three ways: **Permissions list:** A permissions list becomes an inline allow statement over every resource of the tenant (`resources: ['*']`). It is the usual shape for application roles. ```ts const editor = await iam.api.roles.create(credential, { tenantId, name: 'Editor', description: 'Reads and writes documents', permissions: ['documents:read', 'documents:write'], }); // editor.document: // { version: 1, statements: [{ sid: 'RolePermissions', effect: 'allow', // actions: ['documents:read', 'documents:write'], resources: ['*'] }] } ``` **Inline document:** Use a full document when access depends on conditions or should be limited to some resources. ```ts const approver = await iam.api.roles.create(credential, { tenantId, name: 'Approver', document: { version: 1, statements: [ { sid: 'ApproveWithMfa', effect: 'allow', actions: ['invoices:approve'], resources: ['invoice/*'], conditions: { Bool: { 'principal.mfa': true } }, }, ], }, }); ``` **Attached policies:** Stored policies are versioned and reusable across roles. Attach them by ID. ```ts const readOnly = await iam.api.policies.create(credential, { tenantId, name: 'Read documents', document: { version: 1, statements: [{ effect: 'allow', actions: ['documents:read'], resources: ['document/*'] }] }, }); const auditor = await iam.api.roles.create(credential, { tenantId, name: 'Auditor', policyIds: [readOnly.id], }); ``` Passing both `permissions` and `document` fails with `INVALID_INPUT`. Inline documents are validated against the [catalog](/docs/guides/authorization/catalog) and bounded by the role's grant-authority ceilings exactly like attached policies. To change what a job function may do, call `roles.update`. It accepts the same fields as `roles.create`: `document: null` removes the inline document, and `policyIds` replaces the attached set. Everyone who holds the role sees the change at their next request. `roles.get` and `roles.list` read roles back (`iam:roles:read`). When a job function no longer exists, `roles.delete` removes the role together with its bindings and their activations. It is refused with `RESOURCE_IN_USE` while another role inherits it or an access package includes it. To see who would lose what before you delete or edit a role, use the change impact preview ([Change safety](/docs/guides/governance/change-safety)). ## Role inheritance [#role-inheritance] Job functions often build on each other: a *Manager* does everything an *Editor* does, plus approvals. Copying the editor's permissions into the manager role works until someone changes one and forgets the other. Inheritance avoids the copy. A role lists the roles it builds on in `inherits`, and grants the union of its own policies and document and, recursively, everything the roles it inherits grant. ```ts const manager = await iam.api.roles.create(credential, { tenantId, name: 'Manager', permissions: ['reports:export'], inherits: [editor.id, approver.id], }); // Replace the parents; an empty list clears inheritance. await iam.api.roles.update(credential, { tenantId, roleId: manager.id, inherits: [editor.id] }); ``` * A role may inherit at most 20 direct parents, cannot inherit itself, and cannot form a cycle. * Protected roles, such as *Owner*, cannot be inherited (`PROTECTED_RESOURCE`). * Inherited grants are evaluated under the inheriting role's own authority ceilings as well as the inherited role's. A delegated administrator who makes their role inherit a broader one gets no more than their ceiling allows. * A role that others inherit cannot be deleted (`RESOURCE_IN_USE`) until they stop inheriting it. * Configuration sync carries `inherits` by name and applies it once every role of the document exists, so a parent and its child can be introduced together. ## Bindings [#bindings] A role grants nothing until it is bound to someone. A binding gives one role to one subject: an identity (`subjectType: 'identity'`) or a group (`subjectType: 'group'`, which applies to every live member). Binding roles to groups is usually the better habit: people then gain and lose the role as they join and leave the group, without anyone editing bindings. `bindings.create` creates a binding. It requires `iam:bindings:create` on the role (`iam/{roleId}`), so you can let someone hand out some roles and not others, and it records the binding under the caller's grant authority: ```ts // A person await iam.api.bindings.create(credential, { tenantId, roleId: editor.id, subjectType: 'identity', subjectId: alice.id, }); // A group: every current and future member receives the role await iam.api.bindings.create(credential, { tenantId, roleId: auditor.id, subjectType: 'group', subjectId: finance.id, }); ``` By default a binding applies at all times until it is removed. For access that should not last forever, a binding can be narrower than "always": | Option | Effect | Details | | ----------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | `expiresAt` | Stops granting at that time. | [Temporary access](/docs/guides/authorization/temporary-access#expiring-bindings) | | `startsAt` | Grants nothing until then. | [Temporary access](/docs/guides/authorization/temporary-access#future-dated-bindings) | | `window` | An access window: applies only inside recurring hours in a time zone. | [Temporary access](/docs/guides/authorization/temporary-access#access-windows) | | `eligible` | An eligible binding: grants nothing until the subject activates it, for a bounded time. | [Just-in-time elevation](/docs/guides/privileged-access/elevation) | `bindings.delete` (`iam:bindings:delete`) takes a role away, and `bindings.update` changes a binding's dates, window, or eligibility. Both are allowed only to the administrator whose authority issued the binding, or to root; anyone else gets `ACCESS_DENIED` ("Cannot mutate a higher authority binding"). That keeps a junior administrator from undoing a senior one's grants. Protected roles are never bound this way (`PROTECTED_RESOURCE`); ownership has [its own API](#the-owner-role). ### Who holds what [#who-holds-what] Reviews and support questions come down to "what can this person do?" and "who has this role?". These administrative reads answer them, and all require `iam:bindings:read`: * `identities.listBindings({ tenantId, identityId })` returns the effective role set of one person, including roles that reach them through groups. Each entry carries `via` (`'identity'` or the group), the `role`, and, where they apply, the live `activation`, a `pendingActivation`, and `inWindow`. * `roles.listBindings({ tenantId, roleId })` shows who holds a role, with a summary of each person or group. * `bindings.list` searches bindings across the tenant, filtered by `roleId`, `subjectType`, `subjectId`, `eligible`, and `expiresBefore` (for "what ends this month?"). Expired bindings are omitted unless you pass `includeExpired: true` to `bindings.list`. To see what a person can actually do on a specific resource, after conditions and boundaries, use [`policies.effectiveActions`](/docs/guides/authorization/reviews#what-can-a-person-do). ### Groups and deny statements [#groups-and-deny-statements] A group binding reaches every live member, including deny statements in the role. Adding or removing a member can therefore grant or remove denies, so group membership changes require `iam:groups:update` on the group and authority over the group's bindings, not just the right to edit the group's name. ## Grant authorities [#grant-authorities] In a growing organization, not every administrator should be able to grant everything. A support lead should be able to give support roles to their team, but never make someone a tenant administrator. Grant authorities are how Better IAM delegates the right to grant, with a cap. Every role, policy, and binding records the grant authority it was created under, and each authority has a **ceiling**: a policy document that bounds everything issued under it. Whatever a role says, a binding issued under a narrow authority grants no more than that authority's ceiling allows. Ceilings chain: an authority delegated from another is bounded by its own ceiling and every ceiling above it. `authorities.create` delegates a new authority to a person: ```ts // Give the support lead authority to grant support roles, and nothing else. await iam.api.authorities.create(credential, { tenantId, identityId: supportLead.id, ceiling: { version: 1, statements: [{ effect: 'allow', actions: ['tickets:*', 'customers:read'], resources: ['*'] }], }, }); ``` The authority caps what the support lead's grants can reach; it does not give them the right to grant. They also need a role that allows `iam:bindings:create` on the support roles. The two together mean: "may bind these roles, and whatever those roles say, the result never exceeds tickets and customer reads". * `authorities.create` requires `iam:authorities:create` on the identity and a recently authenticated session. Nobody but root can issue authority to themselves. The new authority is a child of one of the caller's own (`parentAuthorityId` picks which), so delegation creates a narrower child authority while retaining its parent chain. * `authorities.revoke` withdraws an authority, for example when the support lead changes teams. It requires recent authentication and is allowed only to the holder of the parent authority (or root). * A caller with no active authority cannot create roles, policies, or bindings (`GRANT_AUTHORITY_REQUIRED`). Root administrators receive a root-issued, unrestricted authority automatically. Grant authorities are retained as references, including the creator's authority for policies and roles. That has three consequences: * **Edits stay with their authority.** A role or policy can be edited only by the holder of the authority that created it, or by root. A lower-authority editor cannot broaden a policy after a superior attaches it, and a higher authority attaching a lower authority's policy keeps the limits under which that policy was created. * **Revocation cascades.** Removing a person's delegation authority disables every grant that depends on it at the next request. An API key also retains its issuing authority's ceiling, and every check it makes is denied once that authority is revoked. * **Membership is not authority.** Removing an administrator's membership alone does not delete access they provisioned earlier; revoke their authority to disable it. A ceiling works like any other boundary: it constrains and never grants access. There is no arbitrary policy-containment solver, so a ceiling is not proved to contain a role when the role is created. Instead, it is applied as a boundary whenever a request is evaluated. See [Boundaries](/docs/guides/authorization/policies#boundaries). ## The Owner role [#the-owner-role] Every tenant needs someone who can always fix its configuration, even after a bad policy edit. That is the *Owner*: a protected role backed by the protected *Owner* policy, which allows everything. So that nobody can lock the tenant out by accident or on purpose, owner role definitions are protected: they cannot be updated, deleted, inherited, bound with `bindings.create`, requested, packaged, assumed, or named in a separation-of-duties rule. Ownership changes go through its dedicated API instead. `identities.setOwner({ tenantId, identityId, owner })` makes a person an owner (`owner: true`) or removes their ownership (`owner: false`). It requires `iam:identities:update` and recent authentication, only an owner (or root) may call it, and it refuses to remove the last active owner (`LAST_OWNER`). ## Assuming a role [#assuming-a-role] Sometimes a person or service needs a role only for one task, or needs to act inside another tenant. Instead of binding the role, a trusted identity can take it on temporarily through role assumption, in a role session whose permissions are exactly the role's. Trusts, `roles.assume`, and their limits are covered in [Temporary access](/docs/guides/authorization/temporary-access#role-sessions). ## Next steps [#next-steps] - [Policy documents](/docs/guides/authorization/policies): Statements, conditions, variables, and boundaries. - [Separation of duties](/docs/guides/authorization/separation-of-duties): Stop anyone from holding two conflicting roles. - [Access packages](/docs/guides/privileged-access/access-packages): Grant bundles of roles and group memberships together. - [Roles API](/docs/reference/api/roles): Every roles method with its signature. # Separation of duties (/docs/guides/authorization/separation-of-duties) > Declare roles nobody may hold together, block every grant that would combine them, and report the conflicts that already exist. Some pairs of duties must never meet in one person. Whoever creates a supplier should not also approve payments to it; whoever writes code should not also approve its release to production. Each role is fine on its own; the combination makes fraud or an unnoticed mistake possible. Auditors call this separation of duties (SoD), and the risky pairs "toxic combinations". Roles are usually granted by many people over time, through direct bindings, groups, access requests, and access packages, so nobody sees the combination forming. A separation-of-duties rule names the roles that must not be combined, and Better IAM checks it on every operation that can grant a role. ## Declare a rule [#declare-a-rule] `sod.create` declares a set of roles nobody may hold together. It requires `iam:sod:manage`. ```ts const rule = await iam.api.sod.create(credential, { tenantId, name: 'Supplier creation vs payment approval', roleIds: [supplierAdmin.id, paymentApprover.id], description: 'Required by the finance controls policy, section 4.2', }); // rule.existingViolations: how many people already hold two of these roles ``` The response carries `existingViolations`, the number of conflicts that already existed when the rule was created. They are reported, but they never block unrelated work. ## What counts as holding a role [#what-counts-as-holding-a-role] A rule looks at every way a person can come to hold a role, including ways that do not grant anything yet: * a direct binding to the person; * a binding to a group they are a live member of; * an eligible (just-in-time) binding, even while it is not activated, because the person can activate it at any moment; * a future-dated binding, so a conflict scheduled to start later is stopped now. Expired bindings and lapsed group memberships do not count. Deleted identities are ignored; disabled ones still count, because they can be re-enabled. Rules compare the roles people are bound to. Role inheritance is not expanded, so name the roles you actually bind: a role that inherits one of the rule's roles is not treated as holding it. ## Prevent mode [#prevent-mode] In `prevent` mode (the default), every operation that can grant a role compares the tenant's conflicts before and after it runs. If the operation would create a new conflict, it fails with `SOD_CONFLICT` (409) and its transaction is rolled back, so nothing is half-applied. The message names the rule and the roles, for example "Separation of duties (Supplier creation vs payment approval): one person cannot hold Supplier admin and Payment approver". The operations checked are: * `bindings.create` and `bindings.update`, which give or change a role binding; * group membership changes, which give a person every role bound to the group; * `identities.createMany`, bulk onboarding with roles and groups; * access-request approval; * access-package assignment and approved package requests; * `config.apply`, configuration as code; * member-invitation acceptance, when the invitation grants roles or groups. Conflicts that already existed when the rule was added never block unrelated work: only operations that create a *new* conflict are refused. That lets you adopt a rule in a tenant that is not clean yet and fix violations at your own pace. A few paths do not block, by design, and rely on reporting instead: * SCIM role mappings from an identity provider are not blocked; their conflicts appear in the reports. * [Automatic package assignment](/docs/guides/privileged-access/access-packages) skips a person whose assignment would conflict (reported in the run's `failed`, audited once, and retried later) and never fails a run or an identity update. ## Detect mode [#detect-mode] A `detect` rule never refuses anything; it only reports. Use it to measure a new rule's impact before enforcing it, or for combinations that are risky but sometimes necessary and reviewed after the fact. ## Find and fix conflicts [#find-and-fix-conflicts] `sod.violations({ tenantId, ruleId? })` lists everyone who currently holds two or more roles of a rule, with names for review screens. It requires `iam:sod:read`. Pass `ruleId` to check one rule. ```ts const violations = await iam.api.sod.violations(credential, { tenantId }); // [{ ruleId, ruleName, mode, identityId, identityName, roleIds, roleNames }] ``` `identityName` is the person's email, or their name when they have none. The [access analysis](/docs/guides/authorization/reviews#scan-for-risky-configuration) also reports each violation as a high-severity `separation-of-duties` finding, so conflicts appear in scheduled risk reports. To fix a violation, remove one of the conflicting grants: delete a binding with `bindings.delete`, remove the person from the group that carries the role, or revoke the access package that granted it. ## Manage rules [#manage-rules] * `sod.list({ tenantId })` returns the tenant's rules, newest first (`iam:sod:read`). * `sod.update({ tenantId, ruleId, name?, description?, roleIds?, mode? })` changes a rule, for example to add a role or switch from `detect` to `prevent` (`iam:sod:manage`). * `sod.delete({ tenantId, ruleId })` removes a rule (`iam:sod:manage`). Rule management is evaluated against `iam/sod/*` for creating and listing, and `iam/sod/{ruleId}` for changing or deleting one rule, so you can let a compliance team manage rules without other administrative rights. ## Separation of duties or an invariant? [#separation-of-duties-or-an-invariant] Both stop dangerous access, from different angles: | | Separation-of-duties rule | Access invariant ([details](/docs/guides/authorization/reviews#guardrails-that-must-always-hold)) | | --------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | Describes | Roles that must not be combined | An outcome: who must or must not be able to perform an action on a resource | | Looks at | Role bindings, including eligible and future-dated ones | The evaluated decision, including conditions, boundaries, and relationships | | Best for | Classic finance and release controls stated in terms of roles | "Contractors can never delete the payroll workspace", whatever roles they hold | Many organizations use both: SoD rules for the controls their auditors list, invariants for the outcomes that must hold regardless of how roles evolve. ## Next steps [#next-steps] - [Roles and bindings](/docs/guides/authorization/roles): How roles reach people directly and through groups. - [Access reviews](/docs/guides/authorization/reviews): Scan for violations and other risks. - [SoD API](/docs/reference/api/sod): Signatures for create, update, list, delete, and violations. # Temporary access (/docs/guides/authorization/temporary-access) > Access that ends, starts later, or applies only in business hours, plus access requests, role sessions, session tokens, web-identity federation for workloads, and expiring API keys. Access tends to pile up. A contractor finishes, an audit ends, someone moves teams, and the roles they were given stay behind because nobody remembers to remove them. The cheapest access to review is access that ends by itself. Better IAM lets you put an end, a start, or a schedule on almost every grant, and gives people a way to ask for access that is time-boxed from the start. | You need | Use | | -------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | Access that ends on a date: a contractor, an audit, a project | [An expiring binding](#expiring-bindings) | | Access that begins later: the first day of a contract | [A future-dated binding](#future-dated-bindings) | | Team membership for a period, with every role the team holds | [A temporary group membership](#temporary-group-memberships) | | Access only during business hours | [An access window](#access-windows) | | People asking for a role, a reviewer approving it for a while | [Access requests](#access-requests) | | Privileged roles held only while needed, with justification and approval | [Just-in-time elevation](#just-in-time-elevation-and-packages) | | A short session with exactly one role, possibly in another tenant | [Role sessions](#role-sessions) | | A short-lived, narrowed copy of your own credential for a script or CLI | [Session tokens](#session-tokens) | | CI jobs and workloads that sign in with their platform's token, no stored secret | [Workloads without stored secrets](#workloads-without-stored-secrets) | | Credentials for a machine that expire and can be scoped | [API keys](#api-keys) | ## Expiring bindings [#expiring-bindings] A binding gives a role to a person or group. By default it lasts until someone removes it. Give it an `expiresAt` when you already know when the access should end: ```ts await iam.api.bindings.create(credential, { tenantId, roleId: auditor.id, subjectType: 'identity', subjectId: externalAuditor.id, expiresAt: Date.parse('2026-12-31T23:59:59Z'), // epoch milliseconds }); ``` * `expiresAt` is in epoch milliseconds, in the future, and within ten years (`INVALID_INPUT` otherwise). * An expired binding grants nothing from that instant. It disappears from `identities.listBindings`, `roles.listBindings`, and `bindings.list` (pass `includeExpired: true` to `bindings.list` to see it), and the purge worker deletes it. * `bindings.update({ tenantId, bindingId, expiresAt })` extends or shortens the end, and `expiresAt: null` clears it. It follows the same rules as `bindings.delete`: the caller needs `iam:bindings:create` on the role and must own the binding's grant authority, or be root. ## Future-dated bindings [#future-dated-bindings] Sometimes you know access should start later: a new hire's first day, or a contract that begins next month. Creating the binding now, with `startsAt`, means nobody has to remember to do it on the day. ```ts await iam.api.bindings.create(credential, { tenantId, roleId: engineer.id, subjectType: 'identity', subjectId: newHire.id, startsAt: Date.parse('2026-10-01T08:00:00+02:00'), expiresAt: Date.parse('2027-03-31T18:00:00+02:00'), // optional; must be after startsAt }); ``` * A future-dated binding is listed with its start (in `identities.listBindings`, `roles.listBindings`, and the [access report](/docs/guides/privileged-access/access-report)'s `starting` section) but grants nothing until then. * `startsAt` may not lie in the past (a minute of clock skew is tolerated) and must be within ten years and before `expiresAt`. * `bindings.update` moves the start, or clears it with `startsAt: null` so the binding applies at once. * [Separation-of-duties rules](/docs/guides/authorization/separation-of-duties) count future-dated bindings, so a conflict scheduled to start later is refused now. ## Temporary group memberships [#temporary-group-memberships] When access comes from a group, it is often the membership that should end: "join the incident team for this week". A temporary membership ends at a set time, and with it every grant and activation the membership carried. ```ts await iam.api.groups.addMember(credential, { tenantId, groupId: incidentTeam.id, identityId: alice.id, expiresAt: Date.now() + 7 * 86_400_000, }); ``` * `groups.addMember` (and `groups.addMembers` for several people) accepts `expiresAt` under the same rules as bindings: in the future and within ten years. * `groups.updateMember({ tenantId, groupId, identityId, expiresAt })` extends or shortens the membership, and `expiresAt: null` makes it permanent. * `groups.listMembers` and `identities.listGroups` report `membershipExpiresAt`, so member lists can show when someone leaves. * Re-adding a lapsed member renews the membership, and the purge worker removes lapsed records (reported as `expiredMemberships`). ## Access windows [#access-windows] Some access should only exist during working hours: a support role that should not be usable at 3 a.m., or contractors who work a fixed schedule. An access window on a binding limits it to recurring hours in a time zone. Outside the window the binding grants nothing, exactly like an expired one, and it starts applying again when the window next opens. ```ts await iam.api.bindings.create(credential, { tenantId, roleId: support.id, subjectType: 'group', subjectId: supportTeam.id, window: { from: '09:00', to: '17:00', timeZone: 'Europe/Berlin', days: [1, 2, 3, 4, 5] }, }); // Change it, or clear it with window: null await iam.api.bindings.update(credential, { tenantId, bindingId, window: null }); ``` `identities.listBindings` reports `inWindow` for windowed bindings, reviews such as `policies.whoCan` and `policies.effectiveActions` reflect the window at evaluation time, and configuration sync carries `window` on group bindings. A policy condition cannot express recurring hours (`DateBefore` and `DateAfter` compare fixed instants), so windows are the tool for schedules. ## Access requests [#access-requests] Without a request flow, people ask for access in chat, an administrator binds a role by hand, and nobody remembers to remove it. An access request turns that into a recorded, time-boxed decision: a member asks for roles with a reason, a reviewer approves or denies, and approved access can expire by itself. ### A member asks [#a-member-asks] A member with `iam:access-requests:create` calls `accessRequests.create`, from an ordinary session of the tenant (not a role session): ```ts const request = await iam.api.accessRequests.create(memberCredential, { tenantId, roleIds: [reportsViewer.id], justification: 'Quarter-end close, ticket FIN-2231', durationSeconds: 14 * 86_400, // optional: how long the access should last }); ``` A request names 1 to 20 roles. Protected roles, such as *Owner*, cannot be requested. A member may hold at most twenty pending requests (`TOO_MANY_REQUESTS`), and an identical pending request is rejected (`CONFLICT`). Requests expire after `accessRequests.lifetimeMs` (default seven days). ### A reviewer decides [#a-reviewer-decides] A reviewer with `iam:access-requests:review` calls `accessRequests.approve` or `accessRequests.deny`, with an optional `note`: ```ts await iam.api.accessRequests.approve(reviewerCredential, { tenantId, requestId: request.id, durationSeconds: 7 * 86_400, // optional: overrides the requested duration note: 'Approved for the close only', }); ``` Approval creates (or refreshes) one binding per role under the reviewer's own grant authority, and requires `iam:bindings:create` on each role, exactly like `bindings.create`. A reviewer can therefore never grant more than they could bind directly. A requester cannot approve their own request, and the requester must still be active. The resulting bindings carry `accessRequestId`, and `expiresAt` whenever a duration was requested or set by the reviewer; without a duration the access is standing until someone removes it. ### The member follows up [#the-member-follows-up] Requesters see their own requests with `accessRequests.listMine`, which needs only `iam:access-requests:create`, and may withdraw a pending one with `accessRequests.cancel`. Administrators list every request with `accessRequests.list` and read one with `accessRequests.get`, both under `iam:access-requests:read`. Two deployment options shape the flow: A request reads as `expired` as soon as its lifetime passes, even before the purge worker marks it, and deciding a request that is no longer pending fails with `INVALID_TRANSITION`. Approvals and denials are audited as `access-request:approve` and `access-request:deny`, with the requester, the roles, and the resulting binding IDs in the metadata. ## Just-in-time elevation and packages [#just-in-time-elevation-and-packages] Two larger features build on temporary access and have their own guides: * **Eligible bindings** record that someone *may* hold a privileged role. The person activates it for a bounded time, optionally with a justification, MFA, and an approver's decision, and the role stops applying when the activation ends. See [Just-in-time elevation](/docs/guides/privileged-access/elevation). * **Access packages** bundle roles and group memberships that are granted together, with an end date, by assignment, by request, or automatically by rule. See [Access packages](/docs/guides/privileged-access/access-packages). ## Role sessions [#role-sessions] A binding gives someone a role for as long as it lasts. Sometimes you want less: a support engineer who needs a customer tenant's support role for fifteen minutes, or an automation that should act with exactly one role and nothing else it holds. Role assumption covers this. A trusted identity starts a short **role session** whose permissions are exactly the target role's. Two steps set it up: 1. **A platform administrator establishes a trust.** `trust.create` records that one exact source identity may assume one exact target role. It is root only and needs recent authentication. 2. **The source identity assumes the role when needed.** `roles.assume` checks the trust and returns a bearer `token` for the target tenant, its `expiresAt`, and a `session` summary that names the role and the trust. ```ts title="Set up once (root administrator)" const trust = await iam.api.trust.create(rootCredential, { tenantId: customerTenantId, // the tenant that owns the role sourceTenantId: rootTenantId, sourceIdentityId: supportEngineer.id, roleId: customerSupportRole.id, requireMfa: true, // the default externalId: 'ticket-system', // optional shared value the caller must present }); ``` ```ts title="Assume the role when needed (the support engineer)" const { token, session } = await iam.api.roles.assume(engineerCredential, { tenantId: customerTenantId, trustId: trust.id, externalId: 'ticket-system', durationSeconds: 900, sessionName: 'ticket-4711', // optional label, visible to policies and the audit trail }); // Use { token } as the credential for calls in the customer tenant until session.expiresAt. ``` The trust's options decide how strict assumption is: And `roles.assume` shapes the session itself: * Assumption also requires the source identity's `iam:roles:assume` permission on the target role (`iam/{targetRoleId}`), evaluated in the source identity's own tenant. The trust must name the caller as its source identity and tenant, MFA must be present when the trust requires it, and the external ID must match. * A role can be assumed from an ordinary user session, an API key, or a session token. Role sessions cannot chain: assuming a role from a role session fails with `ROLE_CHAINING_DISABLED`. An impersonation session cannot assume roles either (`IMPERSONATION_RESTRICTED`). Protected roles cannot be the target of a trust. * The target role's policies replace the source identity's application permissions. The target tenant's boundaries, the trust ceiling, and the session policy still constrain them, and the session carries no groups, no relationships, and no owner or root flags. * The session keeps the MFA state and sign-in time of the credential that started it, and policies see the caller's own tenant as `principal.sourceTenantId`. See [Special sessions](/docs/guides/authorization/conditions#special-sessions) for the full context. * A role session is re-checked on every request. It stops working as soon as the trust is revoked (`trust.revoke`, root only), the source credential or identity stops working, the source identity loses its `iam:roles:assume` permission, or a grant authority behind the role or the source's own grants is revoked. * The target tenant's IP allowlist and network blocks apply to the address presenting the token and to the address it was issued from. * Assumption is audited twice: as `iam:roles:assume` in the source tenant, and as `role:assumed` in the target tenant with the trust, source tenant, duration, format, and tag keys, so the target tenant sees who came in. * `trust.list({ tenantId, includeRevoked? })` shows the trusts that target a tenant's roles, under `iam:trust:read`, without their external ID hashes. The deployment-wide limits for role sessions live under `sts` in the server options; see [Temporary credentials](/docs/operations/deployment/configuration#temporary-credentials). ## Session tokens [#session-tokens] A command-line tool or a script should not carry someone's full browser session or a long-lived API key around. A **session token** is a short-lived copy of your own credential for exactly that: you trade a signed-in session or an API key for a token that lasts an hour (or up to the configured limit), optionally narrowed by a policy, and hand the token to the script. If it leaks, it expires on its own. ```ts title="A one-hour, read-only token for a deploy script" const { token, expiresAt } = await iam.api.sts.getSessionToken( { token: signedInToken }, { mfaCode: '123456', // optional: a fresh authenticator code sessionName: 'deploy-cli', policy: { version: 1, statements: [{ effect: 'allow', actions: ['iam:roles:assume', 'documents:read'], resources: ['*'] }], }, }, ); ``` * The token acts with your own grants, bounded by `policy` and by its source (an API key's scopes and authority carry over). It never outlives its source and ends when the source ends. * Passing `mfaCode` gives the token a fresh MFA time. Role assumption cannot take a code itself, so this is how a command-line tool assumes a role whose trust requires MFA: MFA first, then assume. * Tokens cannot chain: a role session or another session token cannot mint one (`CREDENTIAL_CHAINING_DISABLED`). Each identity holds at most `sts.maxSessionTokensPerIdentity` live tokens (50 by default). * Policies see these credentials as `principal.sessionKind: 'session-token'`, and [`sts.getCallerIdentity`](/docs/reference/api/sts#getcalleridentity) (the CLI's `whoami`) tells any holder what a token acts as. * With `format: 'jwt'` (and `sts.jwt` configured), the token is a signed JWT that other services can verify offline with `createSessionTokenVerifier` from `better-iam/session-tokens`. ## Workloads without stored secrets [#workloads-without-stored-secrets] CI pipelines and cloud workloads traditionally hold an API key in a secret store, and a leaked key works from anywhere until someone notices. Most platforms already give their jobs a short-lived OpenID Connect token that proves which job, repository, or pod is running: GitHub Actions, GitLab CI, Kubernetes service accounts, and cloud workload identity services all do. **Web-identity federation** lets such a job trade that token for a Better IAM role session, so no IAM secret is stored anywhere. ### Enable web identity [#enable-web-identity] Set `sts: { webIdentity: { enabled: true } }` in the server options. It is off by default; see [web-identity federation](/docs/operations/deployment/configuration#web-identity-federation) for the fetch limits and issuer pinning. ### Register the provider [#register-the-provider] An administrator records whose tokens the organization accepts, and for which audience. ```ts const github = await iam.api.oidcProviders.create(credential, { tenantId, name: 'GitHub Actions', issuer: 'https://token.actions.githubusercontent.com', audiences: ['https://iam.example.com'], }); ``` ### Create a web-identity trust [#create-a-web-identity-trust] The trust names the role, the service account the sessions act as, and conditions on the token's claims. The conditions must pin `token.sub`, so a trust can never admit every job on the platform (`WEAK_TRUST_CONDITIONS`). ```ts await iam.api.trust.create(credential, { tenantId, kind: 'web-identity', providerId: github.id, serviceAccountId: deployBot.id, roleId: deployRole.id, conditions: { StringEquals: { 'token.sub': 'repo:acme/app:ref:refs/heads/main' } }, maxSessionSeconds: 900, }); ``` ### Exchange the token in the job [#exchange-the-token-in-the-job] The job asks its platform for a token and calls the public [`sts.assumeRoleWithWebIdentity`](/docs/reference/api/sts#assumerolewithwebidentity). The answer is a role session that lasts minutes, not months. ```bash ID_TOKEN=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://iam.example.com" | jq -r .value) curl -s https://iam.example.com/api/iam/sts/assumeRoleWithWebIdentity \ -H 'Content-Type: application/json' -H 'X-Better-IAM: 1' \ -d "{\"tenantId\":\"$TENANT_ID\",\"trustId\":\"$TRUST_ID\",\"webIdentityToken\":\"$ID_TOKEN\",\"sessionName\":\"deploy-$GITHUB_RUN_ID\"}" ``` Every refusal that depends on the token or on stored state answers the same `WEB_IDENTITY_REJECTED`, so a caller cannot probe which trusts exist. Only a malformed request, a spent exchange budget, or a switched-off feature is refused earlier with its own code, and only a token already admitted by the trust can meet the ordinary limits (such as network rules or the session cap). Administrators see the real reason with [`trust.evaluateWebIdentity`](/docs/reference/api/trust#evaluatewebidentity), which checks a token against a trust without issuing anything. A token can be redeemed once unless the provider turns replay protection off. Disabling the provider, revoking the trust, or disabling the service account ends the sessions at once, and [`oidcProviders.revokeSessions`](/docs/reference/api/oidc-providers#revokesessions) ends them without changing anything else. ## API keys [#api-keys] A service or integration needs a credential that does not depend on a person's session and does not live forever. API keys are opaque credentials for [service accounts](/docs/reference/api/service-accounts), and every key has an expiry and can be scoped below what its service account may do. ```ts const { token, credentialId, expiresAt } = await iam.api.credentials.create(credential, { tenantId, identityId: reportingService.id, name: 'Nightly export', scopes: ['reports:read', 'reports:export'], // or a full session `policy` expiresInSeconds: 30 * 86_400, // default 90 days }); // `token` is shown once; store it in your secret manager. ``` * `credentials.create` issues a key for an active service account. It requires `iam:credentials:create` on the account, recent authentication, and a grant authority. Keys default to a 90-day expiry (60 seconds to 365 days). * The key retains its issuing authority's ceiling. If that authority is revoked, every request with the key is denied. * `scopes` compiles to a session policy that allows exactly those actions on every resource; `policy` accepts a full session policy instead. Passing both fails with `INVALID_INPUT`. * API key sessions are never MFA-verified (`principal.mfa` is `false`) and report `principal.sessionKind` as `api-key`. * `credentials.rotate` replaces the key material: the old key stops working in the same transaction, and the replacement keeps the label, policy, and expiry. `credentials.revoke` deletes a key at once, and `credentials.update` relabels it or moves its expiry (in the future, at most a year out). Finding unused keys, labels, and offboarding are covered in [Access lifecycle](/docs/guides/privileged-access/lifecycle). ## Next steps [#next-steps] - [Just-in-time elevation](/docs/guides/privileged-access/elevation): Eligible roles that people activate when they need them. - [Access report](/docs/guides/privileged-access/access-report): What ends soon, and what starts soon. - [Bindings API](/docs/reference/api/bindings): Every bindings method with its signature. # Access lifecycle (/docs/guides/recipes/access-lifecycle) > Recipes for just-in-time elevation with approvals, scheduled deactivation for contractors, API key hygiene, configuration as code, and offboarding. These recipes keep access time-bound from the day someone joins to the day they leave: privileged roles held only while needed, accounts that switch off on schedule, keys that do not linger, reviewed configuration, and a clean exit. `credential` is the caller's credential (`{ token }` or `{ headers }`). ## Just-in-time elevation instead of standing admin roles [#just-in-time-elevation-instead-of-standing-admin-roles] **The problem:** administrator and production roles held permanently are the most valuable target in any account, and most of the time nobody is using them. **The solution:** make people *eligible* for those roles instead of holding them. An eligible binding grants nothing until the person activates it. An activation lasts a bounded time and can require a reason, MFA, and, for the most sensitive roles, a second person's approval. ```ts // 1. Everyone may activate the roles they are eligible for. const member = await iam.api.roles.create(credential, { tenantId, name: 'Member', permissions: ['iam:bindings:activate'], }); await iam.api.bindings.create(credential, { tenantId, roleId: member.id, subjectType: 'group', subjectId: everyone.id, }); // 2. The on-call group is eligible for incident response: two hours at most, with a reason and MFA. const onCall = await iam.api.bindings.create(credential, { tenantId, roleId: responder.id, subjectType: 'group', subjectId: onCallGroup.id, eligible: true, maxActivationMs: 2 * 3_600_000, requireJustification: true, requireMfa: true, }); // 3. Production administration also needs a second person: the platform team approves. const production = await iam.api.bindings.create(credential, { tenantId, roleId: productionAdmin.id, subjectType: 'group', subjectId: engineers.id, eligible: true, requireApproval: true, approverGroupId: platformTeam.id, // members hold iam:bindings:approve and receive activation-request emails }); // A responder elevates from an MFA session, works, and steps down early. const activation = await iam.api.bindings.activate(responderCredential, { tenantId, bindingId: onCall.id, justification: 'INC-4211', durationMs: 30 * 60_000, }); await iam.api.bindings.deactivate(responderCredential, { tenantId, activationId: activation.id }); // An engineer asks for production access: the activation starts as a request (status 'pending'). const request = await iam.api.bindings.activate(engineerCredential, { tenantId, bindingId: production.id, justification: 'CHG-88', }); await iam.api.bindings.approveActivation(platformCredential, { tenantId, activationId: request.id, durationMs: 45 * 60_000, // optional: the approver sets how long the role stays active }); // Alert on every elevation. await iam.api.webhooks.create(credential, { tenantId, url: 'https://ops.example.com/hooks/iam', events: ['binding:*'], }); ``` * An eligible binding grants nothing until activated, and then only for a bounded time. `maxActivationMs` is one hour by default and seven days at most. * Without `durationMs`, an approval grants the duration the requester asked for. With it, the approver picks any duration from one minute up to the binding's effective maximum, which may be longer than the request. Nobody approves their own request. * Every step is audited (`binding:activate`, `binding:activation-requested`, `binding:activation-approved`, `binding:activation-denied`, `binding:deactivate`), which is why the webhook on `binding:*` makes a good alert. * An organization can set floors for every eligible binding at once with `tenants.setAccessPolicy`, such as a shorter maximum or a required justification. See [Just-in-time elevation](/docs/guides/privileged-access/elevation). ## Contractors: schedule deactivation [#contractors-schedule-deactivation] **The problem:** contractors and temporary service accounts outlive their contracts because nobody remembers to disable them. **The solution:** give the identity an end date (`expiresAt`) when you create it. Its credentials stop working at that moment, and the retention worker then disables it. ```ts await iam.api.identities.create(credential, { tenantId, email: 'contractor@example.com', name: 'Contractor', expiresAt: Date.parse('2027-03-31T00:00:00Z'), }); // Who deactivates in the next 30 days? const expiring = await iam.api.identities.list(credential, { tenantId, expiresBefore: Date.now() + 30 * 86400_000, }); // Extend with a later date, or clear the deadline with null. await iam.api.identities.update(credential, { tenantId, identityId, expiresAt: null }); // Run the retention worker on a schedule: it disables expired identities and records identity:expire. await iam.purgeDeleted(); ``` * Past `expiresAt`, every credential of the identity is refused at once, even before the worker runs. The [retention worker](/docs/operations/jobs#retention-worker) then disables the identity, revokes its sessions, and records `identity:expire`. * Clearing or extending the deadline is the only way to re-enable an expired identity, so nobody quietly turns a contractor back on. * Service accounts accept `expiresAt` too, and [`remind`](/docs/operations/jobs#digest-and-reminders) emails people a week before their account ends. See [Access lifecycle](/docs/guides/privileged-access/lifecycle). ## API key hygiene [#api-key-hygiene] **The problem:** API keys get created for a pipeline or an integration, then forgotten, and keep working long after anyone needs them. **The solution:** label every key and give it an expiry when you create it. Then regularly list the keys nobody has used with `credentials.list` and `unusedForMs`, and revoke them. ```ts const key = await iam.api.credentials.create(credential, { tenantId, identityId: deployer.id, name: 'github-actions', description: 'Deploys from the release workflow', expiresInSeconds: 90 * 86400, }); // key.token is returned once: store it in the pipeline's secrets now. // Keys nobody has used for 30 days, including keys never used since they were issued. const unused = await iam.api.credentials.list(credential, { tenantId, unusedForMs: 30 * 86400_000, }); for (const item of unused) await iam.api.credentials.revoke(credential, { tenantId, credentialId: item.id }); ``` * Keys record `lastUsedAt` when they authenticate a request, which is what `unusedForMs` compares against. * Issue keys with `scopes` (an action allowlist) so an integration never holds more than it needs. * Rotation (`credentials.rotate`) keeps the label and expiry but resets the usage history, so a rotated key shows up as unused until it is put to work. * The nightly [`report`](/docs/operations/jobs#nightly-checks-with-a-credential) and the owners' digest list unused and expiring keys. See [Access lifecycle](/docs/guides/privileged-access/lifecycle#api-key-hygiene). ## Configuration as code [#configuration-as-code] **The problem:** roles and policies edited by hand in production drift from staging, and nobody can review a change before it lands. **The solution:** export a tenant's configuration as one JSON document and keep it in version control. Review changes as pull requests, preview them with `config.plan`, and apply the same file everywhere with `config.apply`. ```ts // Export from staging, keep the file in version control, apply to production. const document = await staging.api.config.export(stagingCredential, { tenantId: stagingTenant }); const plan = await production.api.config.plan(productionCredential, { tenantId: productionTenant, config: document, prune: true, }); console.log( plan.summary, // { create, update, delete, unchanged } plan.changes.filter((change) => change.action !== 'unchanged'), ); await production.api.config.apply(productionCredential, { tenantId: productionTenant, config: document, prune: true, }); ``` ```bash BETTER_IAM_TOKEN=... better-iam config-export --config better-iam.config.mjs --tenant TENANT_ID --output tenant.json BETTER_IAM_TOKEN=... better-iam config-plan --config better-iam.config.mjs --tenant TENANT_ID --input tenant.json --prune BETTER_IAM_TOKEN=... better-iam config-apply --config better-iam.config.mjs --tenant TENANT_ID --input tenant.json --prune # In CI: fail the pipeline when production drifted from the reviewed file. BETTER_IAM_TOKEN=... better-iam config-plan --config better-iam.config.mjs --tenant TENANT_ID --input tenant.json --fail-on-drift ``` * The document holds roles (with inheritance), policies, groups with their members, tenant-defined resource types, group bindings with every activation rule and window, access packages and their rules, the tenant's access policy, invariants, and agreements, keyed by name. * Identities, their direct bindings, and who holds which package are runtime state and stay out of it. * `plan` lists the creates, updates, and deletes without writing; `apply` performs them in one transaction. `prune` also deletes items of a listed kind that the file omits. * `--fail-on-drift` exits non-zero (`CONFIG_DRIFT`) when anything would change, which turns the plan into a CI check. * The commands act as `BETTER_IAM_TOKEN` (`iam:config:read`, and `iam:config:apply` plus the permission for each change). Treat that token as an administrator credential. See [Configuration as code](/docs/guides/privileged-access/config-as-code). ## Offboard a person [#offboard-a-person] **The problem:** when someone leaves, their access is spread across sessions, keys, roles, groups, relationships, and resources they own. Missing one piece leaves a door open. **The solution:** `identities.offboard` removes all of it in one transaction and hands their resources and direct reports to a successor. ```ts const summary = await iam.api.identities.offboard(credential, { tenantId, identityId: leaver.id, reason: 'Left the company (HR-1234)', successorId: manager.id, // takes over the workspaces the leaver owned }); // summary: how many sessions, bindings, memberships, activations, packages, relationships, // accessRequests, and authorities were removed, and resourcesReassigned / reportsReassigned // Later, after the retention period: await iam.api.identities.delete(credential, { tenantId, identityId: leaver.id }); ``` * Offboarding disables the person or service account. It removes their sessions and keys, role bindings, group memberships, activations, package assignments, relationships, pending access requests, and the grant authorities they held. * It transfers ownership of their managed resources to the successor, and a manager's reports move to the successor too. * It is audited as `identity:offboard` with the reason and the counts of everything removed. * The identity stays as a disabled record for retention; `identities.delete` tombstones it later. See [Access lifecycle](/docs/guides/privileged-access/lifecycle). ## Next steps [#next-steps] - [Operations recipes](/docs/guides/recipes/operations): Audit chain, assertions, observability, and webhooks. - [Privileged access](/docs/guides/privileged-access): Elevation, packages, expiry, and configuration as code in depth. # Recipes (/docs/guides/recipes) > Short, copy-ready solutions for common identity and access problems, from sharing and MFA rules to offboarding, plan limits, and audit archiving. The guides explain how each feature works. Recipes start from a problem instead ("a customer requires MFA", "a contractor's access must end in March") and show the few calls that solve it, with the details that matter in production. Every recipe has the same shape: **the problem** in a sentence or two, **the solution** in plain words, then the code and the notes you need before you ship it. Each ends with a link to the guide that explains the feature in full. In the examples, `credential` is the caller's credential: `{ token }` for a session token or API key, or `{ headers }` for an incoming request. Every call is authorized for the caller, applied in one transaction, and audited, like the rest of the API. Unfamiliar terms are defined in the [glossary](/docs/reference/glossary). ## Sharing and access questions [#sharing-and-access-questions] * [Share a resource with relationships](/docs/guides/recipes/sharing-and-reviews#share-a-resource-with-relationships): let people share their own folders without editing policies. * [Answer "who can?" and "what can they do?"](/docs/guides/recipes/sharing-and-reviews#answer-who-can-and-what-can-they-do): explain access to reviewers with the real evaluator. * [Test a policy before saving it, and roll back a bad one](/docs/guides/recipes/sharing-and-reviews#test-a-policy-before-saving-it-and-roll-back-a-bad-one): catch mistakes before they lock people out. * [Role hierarchy and business-hours access](/docs/guides/recipes/sharing-and-reviews#role-hierarchy-and-business-hours-access): build roles on each other and limit when a binding applies. * [Project teams and start dates](/docs/guides/recipes/sharing-and-reviews#project-teams-and-start-dates): memberships and bindings that start and end by themselves. * [Tell people how to get access instead of just "denied"](/docs/guides/recipes/sharing-and-reviews#tell-people-how-to-get-access-instead-of-just-denied): offer step-up, terms, activation, or a package request. * [Guardrails, terms of use, and change previews](/docs/guides/recipes/sharing-and-reviews#guardrails-terms-of-use-and-change-previews): invariants, impact previews, and agreements. ## Sign-in and devices [#sign-in-and-devices] * [Require MFA or restrict sign-in methods for one organization](/docs/guides/recipes/sign-in-and-devices#require-mfa-or-restrict-sign-in-methods-for-one-organization): tighten sign-in for one customer. * [Remember this device after MFA](/docs/guides/recipes/sign-in-and-devices#remember-this-device-after-mfa): skip the code on a trusted browser for a bounded time. * [Devices, sign-out everywhere, and incident response](/docs/guides/recipes/sign-in-and-devices#devices-sign-out-everywhere-and-incident-response): device lists, revoking sessions, and unlocking accounts. ## Support and privacy [#support-and-privacy] * [Support: see the product as a member sees it](/docs/guides/recipes/support-and-privacy#support-see-the-product-as-a-member-sees-it): bounded, audited "view as" sessions. * [Export everything stored about a person](/docs/guides/recipes/support-and-privacy#export-everything-stored-about-a-person): answer data-subject access requests. * [Render the outbox's emails](/docs/guides/recipes/support-and-privacy#render-the-outboxs-emails): send IAM's messages through your provider with your links. ## Tenancy and limits [#tenancy-and-limits] * [Plan limits and usage](/docs/guides/recipes/tenancy-and-limits#plan-limits-and-usage): enforce what each SaaS plan includes and meter usage. * [Bulk onboarding and directory attributes](/docs/guides/recipes/tenancy-and-limits#bulk-onboarding-and-directory-attributes): create people in bulk and map directory data from SCIM. * [Move people over from another system](/docs/guides/recipes/tenancy-and-limits#move-people-over-from-another-system): import organizations, people, and roles, and choose how each group signs in afterwards. * [Store data in libSQL or Turso](/docs/guides/recipes/tenancy-and-limits#store-data-in-libsql-or-turso): local files, replicas, and remote databases. ## Access lifecycle [#access-lifecycle] * [Just-in-time elevation instead of standing admin roles](/docs/guides/recipes/access-lifecycle#just-in-time-elevation-instead-of-standing-admin-roles): eligible roles with justification, MFA, and approval. * [Contractors: schedule deactivation](/docs/guides/recipes/access-lifecycle#contractors-schedule-deactivation): accounts that switch off on their end date. * [API key hygiene](/docs/guides/recipes/access-lifecycle#api-key-hygiene): label, expire, and revoke unused keys. * [Configuration as code](/docs/guides/recipes/access-lifecycle#configuration-as-code): review and apply roles and policies from version control. * [Offboard a person](/docs/guides/recipes/access-lifecycle#offboard-a-person): remove all access in one step and hand resources to a successor. ## Operations [#operations] * [Verify and archive the audit chain](/docs/guides/recipes/operations#verify-and-archive-the-audit-chain): prove the audit log was not edited. * [Call a downstream service with a stateless assertion](/docs/guides/recipes/operations#call-a-downstream-service-with-a-stateless-assertion): let other services trust who is calling. * [Observe latency and outcomes](/docs/guides/recipes/operations#observe-latency-and-outcomes): feed IAM spans to your metrics. * [Webhooks: only denials, only some resources, and redelivery](/docs/guides/recipes/operations#webhooks-only-denials-only-some-resources-and-redelivery): filter events and replay missed deliveries. ## Browse by page [#browse-by-page] - [Sharing and access questions](/docs/guides/recipes/sharing-and-reviews): Relationships, access reviews, policy testing, time-bound roles, access paths, and guardrails. - [Sign-in and devices](/docs/guides/recipes/sign-in-and-devices): Per-organization MFA, remembered devices, and session control. - [Support and privacy](/docs/guides/recipes/support-and-privacy): Impersonation, data-subject export, and email rendering. - [Tenancy and limits](/docs/guides/recipes/tenancy-and-limits): Plan limits, bulk onboarding, migrating from another system, and libSQL storage. - [Access lifecycle](/docs/guides/recipes/access-lifecycle): Just-in-time elevation, expiring accounts, keys, configuration as code, and offboarding. - [Operations](/docs/guides/recipes/operations): Audit chain, assertions, observability, and webhooks. # Operations recipes (/docs/guides/recipes/operations) > Recipes for verifying and archiving the audit chain, calling downstream services with stateless assertions, observing latency, and filtering and redelivering webhooks. These recipes connect Better IAM to the rest of your infrastructure: proving the audit log is intact, letting other services trust who is calling, feeding your monitoring, and routing events to the systems that need them. `credential` is the caller's credential (`{ token }` or `{ headers }`). ## Verify and archive the audit chain [#verify-and-archive-the-audit-chain] **The problem:** an audit log only proves something if you can show that nobody edited it. **The solution:** every tenant's audit records already form a hash chain, the audit chain. Verify it with `audit.verify`, and copy it with `audit.export` to storage your database administrators cannot rewrite. A later tampering attempt then shows up against your copy. ```ts const status = await iam.api.audit.verify(credential, { tenantId }); // { valid, checked, head, failure? } if (!status.valid) alert(`Audit chain broken at ${status.failure?.sequence}: ${status.failure?.reason}`); let from = 1; for (;;) { const page = await iam.api.audit.export(credential, { tenantId, fromSequence: from, limit: 5000, }); await archive.append(page.body); // JSON Lines, in sequence order if (!page.nextSequence) break; from = page.nextSequence; } ``` ```sh better-iam audit-verify --config better-iam.config.mjs --tenant TENANT_ID better-iam audit-export --config better-iam.config.mjs --tenant TENANT_ID --output audit.jsonl ``` * The chain (`sequence`, `previousHash`, `hash`) detects alteration, reordering, or removal by anyone who cannot rewrite both the events and the chain head. Treat your exported heads as the reference. * Archives verify anywhere with `verifyAuditChain(events, { previousHash })` from `better-iam`, including in browsers and workers. * The CLI commands read storage directly: they need no credential and record no audit event. `audit-export` refuses to overwrite an existing file. * For a scheduled, verified, write-once copy, configure `auditArchive` and run [`audit-archive`](/docs/operations/jobs#continuous-audit-archiving) instead of a hand-written loop. See [Audit chain](/docs/guides/events/audit-chain). ## Call a downstream service with a stateless assertion [#call-a-downstream-service-with-a-stateless-assertion] **The problem:** a reporting service or an internal API needs to know who is calling, and in which tenant. It should not call IAM on every request or hold the deployment secret. **The solution:** issue an assertion for that service with `assertions.issue`: a short-lived signed token that describes the caller for one named audience. The service checks it with `verifyAssertion` and a key derived from the secret. ```ts // Issuer: the caller needs iam:assertions:create on iam/reports const { token } = await iam.api.assertions.issue(credential, { tenantId, audience: 'reports', ttlSeconds: 120, }); ``` ```ts title="Downstream service" import { verifyAssertion } from 'better-iam'; // Holds only the derived key (iam.assertionKey()), never the secret. // Throws INVALID_ASSERTION for a forged, expired, or wrong-audience token. const claims = verifyAssertion(token, { key: process.env.IAM_ASSERTION_KEY!, audience: 'reports' }); // claims.sub, claims.tid, claims.roles, claims.groups, claims.mfa ``` From a Next.js server component: `await iamNext.assertion({ tenantId, audience: 'reports' })`. * Assertions are HS256 JSON Web Tokens describing the identity, tenant, session kind, MFA, sign-in method, role and group IDs, and optional public claims. Issuing one is authorized and audited, so administrators decide which roles may obtain tokens for which services. * They grant nothing inside IAM, cannot be exchanged for sessions, and cannot be revoked before they expire. `ttlSeconds` is 10 seconds to one hour (five minutes by default); keep lifetimes short. * The service holding `iam.assertionKey()` cannot recover the secret, but treat the key as a shared secret. During a secret rotation, give services `iam.assertionKeys()`; `verifyAssertion` accepts a list. See [Secrets and keys](/docs/operations/deployment/secrets#assertion-keys). ## Observe latency and outcomes [#observe-latency-and-outcomes] **The problem:** you need IAM's latency, error rates, and denials in the same dashboards as the rest of your service, without adding a dependency. **The solution:** set `observability.onSpan`. It hands you one span per unit of work, with its kind, name, outcome, and duration, to feed any metrics library. ```ts // In the betterIam() options: observability: { onSpan(span) { histogram.observe({ kind: span.kind, name: span.name, outcome: span.outcome }, span.durationMs); if (span.outcome === 'denied') counter.inc({ code: span.code ?? '' }); }, } ``` * Spans cover operations, authorization checks, authentication calls, and HTTP requests. `outcome` is `ok`, `denied` (401, 403, and 429 refusals and advisory denials), or `error`, with the error `code`. * The handler must be synchronous and cheap; exceptions it throws are ignored. * For a ready-made Prometheus endpoint, set `observability.metrics` with a bearer token instead. See [Observability](/docs/operations/observability). ## Webhooks: only denials, only some resources, and redelivery [#webhooks-only-denials-only-some-resources-and-redelivery] **The problem:** a SIEM wants only denied actions, not every event. And after an outage at the receiving end, you need to send the missed events again. **The solution:** filter the webhook subscription with `outcomes` and `resources`, and use its delivery history to redeliver what failed. ```ts const { webhook, secret } = await iam.api.webhooks.create(credential, { tenantId, url: 'https://siem.example.com/iam', events: ['*'], outcomes: ['deny'], resources: ['iam/*'], }); // Store `secret` for the endpoint now: it is returned only once. // After the SIEM was down: send every abandoned delivery again. const history = await iam.api.webhooks.listDeliveries(credential, { tenantId, webhookId: webhook.id, }); for (const delivery of history.filter((item) => item.status === 'failed')) await iam.api.webhooks.redeliver(credential, { tenantId, webhookId: webhook.id, deliveryId: delivery.id, }); ``` * `outcomes` and `resources` (glob patterns over the event's `resourceId`) narrow a subscription beyond its event patterns. `webhooks.update` changes them, and `null` clears them. * Endpoints must verify the HMAC signature and timestamp with `verifyWebhookSignature` before trusting a delivery. * `listDeliveries` returns the newest deliveries (100 by default) with attempts, timestamps, the last error, and a `pending`, `delivered`, or `failed` status, never payloads. * `redeliver` queues the event again, rebuilt from the audit record and signed with the current secret. Endpoints must still deduplicate by event `id`. * Deliveries leave through the outbox, so they need the [`outbox` job](/docs/operations/jobs#outbox-and-audit-hooks). See [Webhooks](/docs/guides/events/webhooks). ## Next steps [#next-steps] - [Events and audit](/docs/guides/events): Subscribers, webhooks, and the audit log in depth. - [Background jobs](/docs/operations/jobs): Scheduling the outbox, archive, and retention workers. # Sharing and access questions (/docs/guides/recipes/sharing-and-reviews) > Recipes for sharing with relationships, answering who can do what, testing and rolling back policies, time-bound roles, access paths, and guardrails. These recipes cover the day-to-day authorization work of a multi-tenant product: letting people share their own things, answering access questions for reviewers, changing policies without surprises, and shaping when access applies. `credential` is the caller's credential (`{ token }` or `{ headers }`), and every call is authorized, applied in one transaction, and audited like the rest of the API. ## Share a resource with relationships [#share-a-resource-with-relationships] **The problem:** people want to share a folder with a colleague or a team, and editing a policy for every share does not scale. **The solution:** use relationships (relationship-based access control, ReBAC). Declare which relations a resource type supports, and write one role that turns relations into permissions. From then on, sharing is creating one relationship: a record that says "this group is a viewer of this folder". ```ts // In the betterIam() options: the resource types and the relations they support. permissions: { resourceTypes: { folder: { managed: true, actions: ['folders:read', 'folders:share'], relations: ['viewer', 'editor', 'owner'] }, file: { managed: true, parent: 'folder', actions: ['files:read'], relations: ['viewer'] }, }, } // One role turns relations into permissions. Bind it to everyone once. const sharing = await iam.api.roles.create(credential, { tenantId, name: 'Sharing', document: { version: 1, statements: [ { effect: 'allow', actions: ['folders:read'], resources: ['folder/*'], conditions: { ArrayContains: { 'resource.relations': ['viewer', 'editor', 'owner'] } } }, { effect: 'allow', actions: ['files:read'], resources: ['file/*'], conditions: { ArrayContains: { 'resource.parentRelations': ['viewer', 'editor', 'owner'] } } }, { effect: 'allow', actions: ['iam:relationships:create'], resources: ['iam/folder/*'], conditions: { ArrayContains: { 'resource.relations': ['owner'] } } }, ], }, }); await iam.api.bindings.create(credential, { tenantId, roleId: sharing.id, subjectType: 'group', subjectId: everyone.id, }); // Owners share their own folders; the server checks their `owner` relation on iam/folder/{id}. await iam.api.relationships.create(ownerCredential, { tenantId, type: 'folder', id: 'plans', relation: 'viewer', subjectType: 'group', subjectId: designTeam.id, }); ``` How it works: * A relationship binds an identity or a group to one resource under a declared relation. The relations the caller holds reach policies as `resource.relations`, and relations on the parent resource as `resource.parentRelations`. So the file statement lets anyone who can see a folder read its files. * `ArrayContains` matches when the caller holds any of the listed relations. * The third statement makes sharing self-service but safe: only people holding `owner` on a folder may create relationships on it. See [Relationships](/docs/guides/authorization/relationships) and [`relationships.create`](/docs/reference/api/relationships#create). ## Answer "who can?" and "what can they do?" [#answer-who-can-and-what-can-they-do] **The problem:** a reviewer or support engineer asks "who can read this folder?" or "what can Alice do here?", and reading every role and policy by hand is slow and error-prone. **The solution:** ask the real evaluator. `policies.whoCan` lists the people who could perform one action on a resource, and `policies.effectiveActions` lists everything one person can do on it. ```ts const { identities, total } = await iam.api.policies.whoCan(credential, { tenantId, action: 'folders:read', resource: { type: 'folder', id: 'plans' }, assumeMfa: true, }); const { allowed } = await iam.api.policies.effectiveActions(credential, { tenantId, identityId: alice.id, resource: { type: 'folder', id: 'plans' }, }); // allowed: the sorted list of actions Alice may perform on the folder ``` * `whoCan` lists every active identity that could perform the action on the resource, with the decision reason, and a `total`. Root administrators are not listed, because their override applies everywhere. It scales with the directory size, so use it on review screens, not per request. * `effectiveActions` evaluates every catalog action (or up to 200 you pass) for one identity on one resource. It returns the sorted `allowed` list plus a reason per action. * Both need `iam:policies:simulate` and evaluate a synthetic session. `assumeMfa: true` simulates an MFA session; otherwise conditions on `principal.mfa` see `false`. The results are advisory and never grant anything. See [Authorization queries](/docs/guides/authorization/queries) and [`policies.whoCan`](/docs/reference/api/policies#whocan). ## Test a policy before saving it, and roll back a bad one [#test-a-policy-before-saving-it-and-roll-back-a-bad-one] **The problem:** a policy edit can lock people out or open access too widely, and you only find out after saving it. **The solution:** test the unsaved document against a concrete request with `policies.test`. If a bad version gets through anyway, make an earlier version current again with `policies.restoreVersion`. ```ts // candidate: the edited policy document, not saved yet const decision = await iam.api.policies.test(credential, { tenantId, document: candidate, action: 'documents:write', resource: 'document/alice-notes', context: { 'principal.id': 'alice', 'principal.mfa': true }, }); if (!decision.allowed) console.warn('The candidate would deny this request:', decision.reason); // Later, if version 4 turned out to be wrong: await iam.api.policies.restoreVersion(credential, { tenantId, policyId, version: 3 }); ``` * `policies.test` (`iam:policies:simulate`) validates the unsaved document against the catalog. It then evaluates it for the action, the resource string, and a context you supply (at most 200 keys). Nothing is stored. * `policies.restoreVersion` (`iam:policies:update`) makes an earlier document current again as a new version; the old document is re-validated against today's catalog. The protected owner policy cannot be restored this way. * `policies.listVersions` lists the versions you can restore. Try documents interactively in the [policy playground](/playground). See [Policies](/docs/guides/authorization/policies). ## Role hierarchy and business-hours access [#role-hierarchy-and-business-hours-access] **The problem:** roles often build on each other (every editor should also read), and some access should apply only during working hours. **The solution:** let one role inherit another instead of copying its permissions, and put an access window on the binding that should only apply at certain times. ```ts const viewer = await iam.api.roles.create(credential, { tenantId, name: 'Viewer', permissions: ['documents:read'], }); const editor = await iam.api.roles.create(credential, { tenantId, name: 'Editor', permissions: ['documents:write'], inherits: [viewer.id], // editors read too }); // Support staff hold their role Monday to Friday, 08:00 to 18:00, Berlin time. await iam.api.bindings.create(credential, { tenantId, roleId: support.id, subjectType: 'group', subjectId: supportTeam.id, window: { from: '08:00', to: '18:00', timeZone: 'Europe/Berlin', days: [1, 2, 3, 4, 5] }, // 0 is Sunday }); ``` * `inherits` lists up to 20 roles whose grants this role includes; cycles and protected roles are refused. * A binding with a `window` applies only inside those hours in the named time zone; outside them it grants nothing. Without `days`, the window applies every day. A window whose `to` is not after `from` wraps past midnight. See [Roles](/docs/guides/authorization/roles) and [Temporary access](/docs/guides/authorization/temporary-access). ## Project teams and start dates [#project-teams-and-start-dates] **The problem:** a lot of access has a natural beginning and end, such as a three-month project or a contract that starts next quarter. Granting it by hand and remembering to remove it later is how standing access piles up. **The solution:** put the dates on the grant itself. A group membership or a binding can start and end by itself. ```ts // A three-month project membership that ends by itself. await iam.api.groups.addMember(credential, { tenantId, groupId: projectTeam.id, identityId: alice.id, expiresAt: Date.now() + 90 * 86400_000, }); // Access that begins on the contract's first day and ends on its last. await iam.api.bindings.create(credential, { tenantId, roleId: contractor.id, subjectType: 'identity', subjectId: bob.id, startsAt: Date.parse('2027-01-04T08:00:00Z'), expiresAt: Date.parse('2027-06-30T18:00:00Z'), }); ``` * A future `startsAt` makes the binding visible with its start date, but it grants nothing until then. * An `expiresAt` stops the grant at that instant; the [retention worker](/docs/operations/jobs#retention-worker) removes the record later. An expiring group membership ends every grant and activation the membership carried. See [Temporary access](/docs/guides/authorization/temporary-access). ## Tell people how to get access instead of just "denied" [#tell-people-how-to-get-access-instead-of-just-denied] **The problem:** a bare "access denied" sends people to an administrator even when they could fix it themselves. **The solution:** when a check fails, call `accessPaths.find` to ask what the person could do about it: step up to MFA, accept the terms of use, activate an eligible role, or request an access package. Every option is verified by simulating it in a transaction that is rolled back, so the list never promises access that would still be refused. ```ts const check = await iam.authorize({ token, tenantId, action: 'documents:delete', resource }); if (!check.allowed) { const { paths } = await iam.api.accessPaths.find( { token }, { tenantId, action: 'documents:delete', resource }, ); for (const path of paths) { if (path.kind === 'mfa') showStepUp(); if (path.kind === 'accept-agreements') showTerms(path.agreements); // then agreements.accept if (path.kind === 'activate') offerActivation(path.bindingId, path.role, path.requireJustification); // bindings.activate if (path.kind === 'request-package') offerRequest(path.package); // packages.request } if (!paths.length) showAskAnAdministrator(); } ``` * The call needs only the person's own ordinary session. * Activation and package options appear only when the person holds `iam:bindings:activate` or `iam:packages:request` for them. Approval requirements are reported, so the UI can say "your request goes to an approver". * Like `authorize`, a denial's `reason` is always `ACCESS_DENIED`, so the call does not reveal which rule refused. * An empty list means only an administrator can help. The `useAccessPaths` hook wraps the call for React and Vue. See [Access paths](/docs/guides/governance/access-paths). ## Guardrails, terms of use, and change previews [#guardrails-terms-of-use-and-change-previews] **The problem:** some rules must hold however roles are edited ("sales never approves payments"), some access should wait until people accept the rules, and every role edit risks a surprise. **The solution:** three governance features. An invariant states a rule and refuses changes that break it. An impact preview shows who gains or loses what before you edit. An agreement records who accepted your terms, and a policy can require it. ```ts // Sales may never approve payments, whatever roles say; refuse changes that would break this. // `department` must be a declared identity attribute. await iam.api.invariants.create(credential, { tenantId, name: 'Sales never approves payments', subject: { attribute: { name: 'department', value: 'Sales' } }, action: 'payments:approve', resource: { type: 'ledger', id: 'main' }, expect: 'deny', mode: 'enforce', }); // Before editing a role: who gains or loses what, and which invariants would break? const preview = await iam.api.impact.preview(credential, { tenantId, change: { role: { roleId, permissions: ['payments:read', 'payments:approve'] } }, resources: [{ type: 'ledger', id: 'main' }], }); // Terms of use that policies can require (deny while principal.pendingAgreements > 0). await iam.api.agreements.create(credential, { tenantId, name: 'Acceptable use', content: '...' }); ``` * **Invariants** with `mode: 'enforce'` are re-checked around every access-changing operation. A change that would newly break one is refused with `INVARIANT_VIOLATION` (409) and rolled back. `monitor` mode only reports; schedule [`monitor-invariants`](/docs/operations/jobs#certifications-and-invariants) to be alerted. * **Impact previews** apply the change with the real validation and permissions inside a transaction that is always rolled back, and evaluate every holder before and after. `preview.identities` lists who gains and loses which actions on each resource, and `preview.invariants.broken` the guardrails the change would break. * **Agreements** are enforced by an ordinary policy statement, for example a deny on `documents:*` while `principal.pendingAgreements` is greater than 0. Members accept with `agreements.accept`. See [Change safety](/docs/guides/governance/change-safety) and [Terms of use](/docs/guides/governance/agreements). ## Next steps [#next-steps] - [Sign-in and devices recipes](/docs/guides/recipes/sign-in-and-devices): Per-organization MFA, remembered devices, and session control. - [Authorization guides](/docs/guides/authorization/policies): How policies, roles, and conditions are evaluated. # Sign-in and devices (/docs/guides/recipes/sign-in-and-devices) > Recipes for per-organization MFA and sign-in method rules, remembering trusted browsers after MFA, device lists, sign-out everywhere, and incident response. These recipes cover how people sign in and what happens to their sessions afterwards: tightening sign-in for one organization, sparing people a code on a browser they use every day, and ending sessions when something goes wrong. `credential` is the caller's credential (`{ token }` or `{ headers }`), and `client` is a browser client from `createIamClient`. ## Require MFA or restrict sign-in methods for one organization [#require-mfa-or-restrict-sign-in-methods-for-one-organization] **The problem:** one customer's security team requires MFA, or allows only passkeys and their own identity provider, while your other customers do not. **The solution:** give that tenant an authentication policy with `tenants.setAuthPolicy`. The rules apply to that one organization, without changing the deployment. ```ts await iam.api.tenants.setAuthPolicy(credential, { tenantId, authPolicy: { requireMfa: true, allowedMethods: ['passkey', 'federated'], sessionIdleTimeoutMs: 30 * 60_000, }, }); ``` A tenant policy can only tighten the deployment's configuration. Existing sessions are re-checked on their next request, so requiring MFA locks out sessions without MFA immediately. * Setting the policy needs `iam:tenants:update` and recent authentication. `authPolicy: null` removes it. * `allowedMethods` accepts `password`, `passwordless-email`, `passwordless-sms`, `passkey`, and `federated`. Method restrictions are checked before any credential is examined, so a rejected method never reveals whether a password was right. * `requireMfaForOwners` requires MFA from owners only. It protects the people who can change the policy first, before you require it from everyone. * The same policy holds `sessionLifetimeMs`, `maxSessions`, `allowedIpRanges`, `trustedDeviceDays`, `notifyNewSignIn`, `mfaEmailCodes`, `bindSessionsToIp`, password rules, and `allowImpersonation`. To apply a policy to every new organization, set `tenantDefaults.authPolicy` in the [configuration](/docs/operations/deployment/configuration#tenancy-and-catalog). See [Tenant policy](/docs/guides/authentication/tenant-policy). ## Remember this device after MFA [#remember-this-device-after-mfa] **The problem:** asking for an MFA code on every sign-in from the same laptop trains people to resent MFA, but skipping MFA defeats its purpose. **The solution:** let people remember a browser when they complete MFA, with `rememberDevice: true`. That browser skips the second factor for a bounded time, while a stolen password alone is still not enough anywhere else. ```ts const outcome = await client.auth.signIn({ tenantId, email, password }); if ('mfaRequired' in outcome) { // The server sets the device cookie; API clients receive deviceToken in the body instead. await client.auth.verifyMfa({ tenantId, challenge: outcome.challenge, code, rememberDevice: true, }); } // Later sign-ins from this browser skip the code until the device expires or is forgotten. const devices = await client.auth.listTrustedDevices(); // for an "Your devices" page await client.auth.revokeTrustedDevice({ deviceId }); // forget one device await client.auth.revokeTrustedDevices(); // forget all of them ``` * Tenants set how long a device is remembered with `authPolicy.trustedDeviceDays` (0 disables it). The deployment caps it with `authentication.trustedDeviceLifetimeMs` (30 days by default, one year at most). * A password, email, or factor change forgets every device. * Through the HTTP handler, the device token lives in its own HttpOnly `better-iam.device` cookie and is sent with later sign-ins automatically, so the client only passes `rememberDevice: true` once. * Root administrators' browsers are never remembered. When an administrator revokes a person's sessions, that person's remembered devices are forgotten too. See [MFA](/docs/guides/authentication/mfa) and [Sessions](/docs/guides/authentication/sessions). ## Devices, sign-out everywhere, and incident response [#devices-sign-out-everywhere-and-incident-response] **The problem:** people want to see where they are signed in and end the session on a lost phone. During an incident, administrators need to cut off one account or a whole organization, and to unlock someone who tripped the rate limit. **The solution:** each case has one call. People manage their own sessions through `auth`; administrators use `identities` and `tenants`. ```ts const sessions = await iam.api.auth.listSessions(credential); // each with client.userAgent / ip / label await iam.api.auth.revokeOtherSessions(credential); // the caller keeps this session await iam.api.identities.revokeSessions(adminCredential, { tenantId, identityId }); // one member await iam.api.tenants.revokeSessions(adminCredential, { tenantId }); // everyone else in the tenant await iam.api.identities.unlock(adminCredential, { tenantId, identityId }); // clear rate-limit lockouts ``` * `auth.listSessions` returns the caller's sessions with their client details, for a device list. `auth.revokeOtherSessions` ends all the others and requires recent authentication. * `identities.revokeSessions` (`iam:identities:update`) ends one member's sessions without disabling the account, and forgets their remembered devices. `tenants.revokeSessions` (`iam:tenants:update`) ends every session in the tenant, keeping the caller's unless `includeSelf` is set. Both require recent authentication and are audited. * `identities.unlock` clears the rate-limit counters behind a person's sign-in, recovery, and MFA flows (audited as `identity:unlock`). It never clears per-IP counters. Custom limiters support it by implementing `reset`. * Device lists show IP addresses only when the server knows them. Behind a proxy, set the `http.clientInfo` option of `betterIam()`: a function that returns the request's real `ip`, `userAgent`, and a `label`. * For attacks from a known network, block it with `security.blockNetwork`; see the [security model](/docs/operations/security#network-blocks-and-ip-bound-sessions). ## Next steps [#next-steps] - [Support and privacy recipes](/docs/guides/recipes/support-and-privacy): View as a member, data-subject export, and email rendering. - [Tenant policy](/docs/guides/authentication/tenant-policy): Every field of the per-organization authentication policy. # Support and privacy (/docs/guides/recipes/support-and-privacy) > Recipes for letting support staff view the product as a member, exporting everything stored about a person, and rendering the outbox's emails. These recipes cover the work around your users rather than their access: helping them when something looks wrong, answering data-subject requests, and sending the emails Better IAM queues in your own style. `credential` is the caller's credential (`{ token }` or `{ headers }`). ## Support: see the product as a member sees it [#support-see-the-product-as-a-member-sees-it] **The problem:** a member reports "I can't open the Q3 report", and support cannot reproduce it without seeing what that member sees. Asking for their password is unacceptable, and broad support access would hide the member's real permissions. **The solution:** let support open an impersonation ("view as") session as the member with `identities.impersonate`. The session is bounded in time, needs a stated reason, and is audited. ```ts // Once per organization, by an owner: await iam.api.tenants.setAuthPolicy(ownerCredential, { tenantId, authPolicy: { allowImpersonation: true }, }); // Support staff hold a role with iam:identities:read and iam:identities:impersonate. const viewAs = await iam.api.identities.impersonate(supportCredential, { tenantId, identityId, reason: 'Ticket 1234: cannot open the Q3 report', durationMs: 30 * 60_000, }); // viewAs.token authenticates as the member; viewAs.session.impersonatorId names the agent. await iam.api.auth.signOut({ token: viewAs.token }); // or let it expire ``` The session can read and act as the member within their permissions. It cannot do anything that needs recent authentication, assume roles, grant OAuth consent, or impersonate further. To keep support away from sensitive product actions, add a deny statement with the condition `{ "Bool": { "principal.impersonated": true } }`. * Every audit record and webhook body carries `impersonatorId`. The member sees the session in their own session list, and the token is returned in the body only, never as a cookie. * Opening one needs recent authentication, an ordinary session of the agent's own, and a reason; it is audited as `identity:impersonate`. Owners, root administrators, service accounts, and the caller themselves cannot be impersonated. * `durationMs` is one minute to eight hours (one hour by default). The session never outlives the agent's own session, and ends the moment the agent signs out or is disabled. * The console's member page offers "View as" with a banner and a stop button when the policy is on. See [Impersonation](/docs/guides/authentication/impersonation) and the [security model](/docs/operations/security#impersonation). ## Export everything stored about a person [#export-everything-stored-about-a-person] **The problem:** a person exercises their right of access under privacy law, or an investigation needs one account's complete footprint. Collecting it collection by collection misses things. **The solution:** `identities.export` returns everything stored about one identity as a single bundle. ```ts const bundle = await iam.api.identities.export(adminCredential, { tenantId, identityId }); // identity, sessions, mfa, passkeys, externalIdentities, bindings, groups, relationships, // accessRequests, boundaries, grantAuthorities, links, scim, and audit (when the caller may read it) ``` * The call needs recent authentication and `iam:identities:read` on the identity. Each export is audited as `identity:export`. * Secrets never leave: sessions come without token hashes, and MFA and passkeys are reported by enrolment and identifier only. * The `audit` section (the newest 5000 events the identity performed) is included only when the caller also holds `iam:audit:read`; `auditIncluded` says whether it was. To erase the person afterwards, use [`identities.delete`](/docs/reference/api/identities#delete). It leaves a tombstone without email or secrets, so audit records stay resolvable. ## Render the outbox's emails [#render-the-outboxs-emails] **The problem:** Better IAM queues invitations, password resets, verification links, and security notices in its outbox, but your mail provider sends them, and their links must point at your application. **The solution:** in your `authentication.sendEmail` callback, call `renderDeliveryMessage`. It turns a queued message into a subject, plain text, and HTML, with links built by your own functions. ```ts import { renderDeliveryMessage } from 'better-iam/auth/templates'; // In the betterIam() options: authentication: { sendEmail: async (message) => { const rendered = renderDeliveryMessage(message, { appName: 'Acme Cloud', links: { invitation: ({ kind, tenantId, token }) => `https://acme.example/join?kind=${kind}&tenant=${tenantId}&token=${token}`, passwordReset: ({ tenantId, token }) => `https://acme.example/reset?tenant=${tenantId}&token=${token}`, verifyEmail: ({ tenantId, token }) => `https://acme.example/verify?tenant=${tenantId}&token=${token}`, }, }) ?? renderOwnTemplate(message); // your renderer for access-digest, expiry-reminder, and the rest if (!rendered) throw new Error(`Unknown template ${message.template}`); // the outbox retries it await mailer.send({ to: message.to, subject: rendered.subject, text: rendered.text, html: rendered.html }); }, }, ``` * Built-in templates: `verify-email`, `password-reset`, `email-change`, `magic-link`, `code`, `mfa-code`, `new-sign-in`, `sign-in-failures`, `certification-review`, `certification-reminder`, `owner-invitation`, and `member-invitation`. * For any other template, `renderDeliveryMessage` returns `undefined`. That covers plugin templates and templates such as `access-digest`, `expiry-reminder`, `activation-request`, and `activation-decided`. Render those from the message's `payload` yourself, as `renderOwnTemplate` does above. Throwing makes the outbox retry the message, so throw only for templates you really cannot send. * `links` also accepts `emailChange`, `magicLink`, `account` (the person's account page, used by security notices), and `certification` (a campaign's review page). A missing link builder falls back to the raw token. * Delivery is at least once, so deduplicate by message `id`, and never log tokens or payloads. See [Scheduled jobs](/docs/operations/jobs#outbox-and-audit-hooks) for how the outbox retries. ## Next steps [#next-steps] - [Tenancy and limits recipes](/docs/guides/recipes/tenancy-and-limits): Plan limits, bulk onboarding, and libSQL storage. - [Impersonation](/docs/guides/authentication/impersonation): How "view as" sessions are bounded and audited. # Tenancy and limits (/docs/guides/recipes/tenancy-and-limits) > Recipes for SaaS plan limits and usage metering, bulk onboarding with directory attributes from SCIM, moving people over from another login system, and storing data in libSQL or Turso. These recipes cover running Better IAM as the identity layer of a multi-tenant SaaS product: enforcing what each plan includes, bringing whole teams in at once, and choosing where the data lives. `credential` is the caller's credential (`{ token }` or `{ headers }`). ## Plan limits and usage [#plan-limits-and-usage] **The problem:** your pricing plans include a number of seats, webhooks, or roles, and the limit has to hold on every path that creates a record, not only in your own signup form. **The solution:** set plan limits on the tenant with `tenants.setLimits`. They are checked inside every creation transaction, and `tenants.usage` reports the current counts for dashboards and billing. ```ts await iam.api.tenants.setLimits(rootCredential, { tenantId, limits: { identities: 25, webhooks: 5 }, }); const usage = await iam.api.tenants.usage(credential, { tenantId }); // usage: identities, mfaEnrolled, serviceAccounts, groups, roles, policies, resources, // relationships, webhooks, activeSessions, and the limits ``` Creation past a limit fails with `LIMIT_EXCEEDED` on every path, including invitation acceptance, SCIM, and bulk creation. * Only root administrators set limits, and each change is audited as `tenant:limits`. `limits: null` removes them. * Limits cover members (`identities`), `serviceAccounts`, `groups`, `roles`, `policies`, registered `resources`, and `webhooks`. Deleted tombstones do not count. * The check also covers self-registration and federation, and invitations accepted after the limit was reached. * `tenants.usage` reports the current counts, how many members enrolled MFA, the active sessions, and the limits, for dashboards and metering. * To give every new organization a default plan, set `tenantDefaults.limits` in the [configuration](/docs/operations/deployment/configuration#tenancy-and-catalog). It is stamped on every tenant that `tenants.create` creates. See [Tenants and identities](/docs/guides/concepts/tenants-and-identities). ## Bulk onboarding and directory attributes [#bulk-onboarding-and-directory-attributes] **The problem:** a new customer arrives with a spreadsheet of fifty people, or with an identity provider that should keep their directory in sync. Your policies need facts about those people, such as each person's department. **The solution:** create people in bulk, with attributes and roles, using `identities.createMany`. For a directory that changes, let SCIM provision people and map the enterprise directory into the same declared attributes with `mapAttributes`. ```ts await iam.api.identities.createMany(credential, { tenantId, identities: rows.map((row) => ({ email: row.email, name: row.name, attributes: { department: row.department }, roleIds: [editor.id], })), }); ``` ```ts title="iam.ts" import { createScimService } from 'better-iam/scim'; // SCIM: map the enterprise extension to declared identity attributes. const scim = createScimService({ ...iam.protocolHost, mapAttributes: ({ title, enterprise }) => ({ ...(title ? { title } : {}), ...(typeof enterprise?.department === 'string' ? { department: enterprise.department } : {}), }), }); iam.useProtocol(scim); ``` * `identities.createMany` creates up to 100 identities in one transaction, applying attributes, roles, and groups under the caller's authority. Plan limits and password rules apply as for single creation. * Attributes must be declared in `permissions.identityAttributes`. They reach policies as `principal.department` and so on. * SCIM stores the enterprise user extension (`department`, `costCenter`, `manager`, and the rest) as provisioned. `mapAttributes` turns it into declared attributes, which the host validates, so the directory drives policy conditions. The extension's manager also becomes the person's manager for approvals. See [SCIM](/docs/federation/scim) and [Tenants and identities](/docs/guides/concepts/tenants-and-identities). ## Move people over from another system [#move-people-over-from-another-system] **The problem:** you are replacing an existing login system, either your own users table or a hosted provider, and the people in it must keep their organization and their access without signing up again. **The solution:** recreate the organizations first, import people in batches with the old user ID kept as an attribute, then let each group of people in the way that matches how they sign in today. Credentials themselves do not move: no API accepts a password hash, and passkeys and authenticator apps are registered with the system that issued them. What moves is who people are, where they belong, and what they may do. ### Recreate organizations [#recreate-organizations] Create one tenant per organization with `tenants.create`, which emails the organization's owner an invitation. A new tenant stays `pending` until that owner accepts, and members cannot be created in a pending tenant, so send these invitations first and import each organization once it is active. Create the roles its people need with `roles.create`, or apply them from [configuration as code](/docs/guides/privileged-access/config-as-code). ### Import people in batches [#import-people-in-batches] Declare an attribute for the old ID, then create people 100 at a time. Results come back in the order you sent them, so you can record which new identity replaced which old user. ```ts title="iam.ts" export const iam = betterIam({ // ... permissions: { identityAttributes: { legacyId: 'string' } }, }); ``` ```ts title="migrate.ts" for (let start = 0; start < users.length; start += 100) { const batch = users.slice(start, start + 100); const { identities } = await iam.api.identities.createMany(credential, { tenantId, identities: batch.map((user) => ({ email: user.email, name: user.name, attributes: { legacyId: user.id }, roleIds: [user.isAdmin ? adminRoleId : memberRoleId], })), }); // identities[i] replaces batch[i]: keep the mapping for your own tables. await saveMapping(batch.map((user, i) => [user.id, identities[i]!.id])); } ``` The caller needs `iam:identities:create` in the tenant and, to hand out roles, the right to grant each one and an active grant authority there; see [`createMany`](/docs/reference/api/identities#createmany). Each batch is one transaction: if any entry fails, nobody in that batch is created, so a script that stops can resume at the batch that failed. A batch that contains an address already imported fails with `IDENTITY_EXISTS`, so skip people you have already mapped when you re-run. ### Let people in [#let-people-in] Imported accounts are active, have no password, and have an unverified email. Choose how each group signs in: | People who today | Do this | | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Sign in with a password | Turn on magic links (`passwordlessEmail`), so they sign in with their address and the address becomes verified; or send each a reset link with [`identities.requestPasswordReset`](/docs/reference/api/identities#requestpasswordreset). | | Sign in with Google, GitHub, Microsoft, or their company's identity provider | Do not import them. Connect the [provider](/docs/federation/oauth-sign-in) and let their first sign-in create the account with a verified email. | | Are managed by their company's directory | Let the directory create and update them through [SCIM](/docs/federation/scim). | > **Two traps.** The public "forgot password" flow sends nothing to an unverified address, so imported people cannot reset a password on their own until they have signed in once with a magic link or an administrator's reset link. And an imported account blocks federated sign-in for the same address: accounts are never merged by email, so the first sign-in through a provider fails with `ACCOUNT_LINK_REQUIRED` until the person signs in another way and [links the provider](/docs/federation/oauth-sign-in#link-an-existing-account). ### Switch over [#switch-over] Sessions from the old system do not carry over, so everyone signs in once after the switch. Before you announce it, compare `tenants.usage` for each tenant with the old system's counts, and spot-check a few people with [`policies.effectiveActions`](/docs/guides/authorization/reviews#what-can-a-person-do) to confirm their roles came across. See [Tenants and identities](/docs/guides/concepts/tenants-and-identities) and [sign-in methods](/docs/guides/authentication/sign-in-methods#magic-links-and-one-time-codes). ## Store data in libSQL or Turso [#store-data-in-libsql-or-turso] **The problem:** you want SQLite's simplicity, but the database must be reachable from several regions or serverless instances, or you want a local replica close to the application. **The solution:** use the libSQL storage adapter. One set of options covers local files, encrypted files, embedded replicas, and remote Turso or sqld databases. ```ts title="iam.ts" import { betterIam } from 'better-iam'; import { libsqlAdapter } from 'better-iam/adapter-libsql'; const iam = betterIam({ // ... database: libsqlAdapter({ url: 'libsql://name-org.turso.io', authToken: process.env.TURSO_AUTH_TOKEN, }), }); ``` * `url` also accepts `file:./iam.db`, a plain path, `:memory:`, and `https:` or `wss:` URLs. `syncUrl` with a local file makes an embedded replica, and `encryptionKey` encrypts a local file. * Remote servers queue write transactions themselves. Locally, transactions run one at a time and take the writer lock before any read. * Local libSQL writes cost more than the SQLite adapter's, because `@libsql/client` compiles each statement on every call. See [Storage adapters](/docs/operations/storage) for every option, and for moving an existing deployment with `store-copy`. ## Next steps [#next-steps] - [Access lifecycle recipes](/docs/guides/recipes/access-lifecycle): Just-in-time elevation, expiring accounts, keys, and offboarding. - [SCIM](/docs/federation/scim): Connections, attribute mapping, and group sync. # Access packages (/docs/guides/privileged-access/access-packages) > Bundle roles and group memberships into packages that administrators assign, members request with approval, and rules grant automatically. Some access always comes as a set. A new engineer needs the Reader role and the Engineering group; a contractor needs a vendor profile for the length of the contract; a project member needs the project's roles and channels. Granting those parts one by one is slow, and removing them later is worse: somebody has to remember every piece. An access package names such a set once. *Assigning* it to a person grants every part as ordinary role bindings and group memberships in one transaction, all ending on the same date. Revoking the assignment, or reaching its end date, takes exactly that set away again and nothing else. Members can also *request* a package themselves, and an approver decides. A package is a convenience, never a bypass: whoever assigns or approves it still needs the right to grant each part by hand. ## Define a package [#define-a-package] `packages.create` defines what a package contains and the rules for granting it. It needs `iam:packages:create`. ```ts title="An onboarding kit" const kit = await iam.api.packages.create(credential, { tenantId, name: 'Engineering onboarding', description: 'Reader role and the Engineering group', roleIds: [reader.id], groupIds: [engineering.id], maxDurationMs: 365 * 86400_000, requireJustification: true, }); ``` The other definition calls: * `packages.update` changes a package's contents or rules. `null` clears the description, the duration cap, or the approver group. Content changes apply to future assignments only (automatic holders are updated too). * `packages.list` and `packages.get` return packages with their live holder counts, for an overview page. * `packages.delete` removes a package nobody holds; while someone holds it, it fails with `RESOURCE_IN_USE`. ## Assign and revoke [#assign-and-revoke] Administrators grant a package with `packages.assign`, move its end date with `packages.extend`, and take it away with `packages.revoke`: ```ts title="Assign for a contract, then extend or revoke" const assignment = await iam.api.packages.assign(credential, { tenantId, packageId: kit.id, identityId: newHire.id, expiresAt: Date.parse('2027-06-30T18:00:00Z'), justification: 'Joined the platform team (HR-2291)', }); // assignment.created: { bindings, memberships }, assignment.skipped: groups left alone // The contract was extended. await iam.api.packages.extend(credential, { tenantId, packageId: kit.id, identityId: newHire.id, expiresAt: Date.parse('2027-12-31T18:00:00Z'), // or null for no end }); // The project ended early. await iam.api.packages.revoke(credential, { tenantId, packageId: kit.id, identityId: newHire.id }); ``` ### What an assignment creates [#what-an-assignment-creates] `packages.assign({ tenantId, packageId, identityId, expiresAt?, justification? })` turns every role into an ordinary identity binding and every group into an ordinary membership, all ending at `expiresAt`. They are created under the caller's grant authority and tagged with the assignment. The details matter when a person already has some of the access: * **Rights.** The caller needs `iam:packages:assign` on the package plus what the direct calls need: `iam:bindings:create` on each role and `iam:groups:update` on each group. A package never widens what its assigner could grant by hand. * **Roles.** Each role becomes a binding of the assignment's own, even when the person already holds the role another way. A package never depends on, replaces, or removes a binding granted by hand. * **Groups.** A group has one membership record per person. A membership that already lasts at least as long is left alone and reported in `skipped`; a shorter one is extended and becomes the assignment's. When two packages of the same person share a group, the membership belongs to whichever needs it longest and passes to the other when that one is revoked or shortened. * **Hand edits.** Editing a package's binding or membership by hand (`bindings.update`, `groups.updateMember`, re-adding a lapsed member) takes the record over: revoking the package no longer removes it. * **Rules.** `maxDurationMs` makes an end date mandatory and caps it, and `requireJustification` makes the justification mandatory (`INVALID_INPUT` otherwise). * **Repeat assignments.** Assigning a package the person already holds by hand fails with `CONFLICT`. Assigning one they hold through a package rule takes it over as a manual assignment (`replacedAutomatic: true`). An assignment whose bindings no longer grant, for example because its assigner's authority was revoked when they were offboarded, is reported as `broken` and may simply be assigned or requested again. ### Ending an assignment [#ending-an-assignment] * **Revoke.** `packages.revoke({ tenantId, packageId, identityId })` removes exactly the records the assignment added. It needs only `iam:packages:assign` on the package, because the assignment owns those records whichever authority issued them. * **Expire.** An assignment ends by itself at `expiresAt`. The purge worker later sweeps it together with its bindings and memberships. * **Extend or shorten.** `packages.extend({ tenantId, packageId, identityId, expiresAt })` moves the end of the assignment and everything it created at once. Shortening needs only `iam:packages:assign`. Lengthening (or `null`, no end) is granting, so it needs the same rights as `assign` plus a grant authority, and the package's bindings move to the extender's authority so the longer grant is bounded by what the extender may give. * **Leave.** Offboarding or deleting an identity removes its assignments. An assignment made by a [package rule](/docs/guides/privileged-access/automatic-assignment) follows the rule instead: `packages.revoke` refuses it while the rule exists, and `packages.extend` always refuses it (`INVALID_TRANSITION`). To take the package from one person, exclude them in the rule. To give them a fixed end, assign the package to them by hand, which turns the assignment into a manual one. ### Who holds what [#who-holds-what] `packages.listAssignments({ packageId?, identityId?, includeExpired?, source? })` lists holders, for a package's holder list or a person's detail page. `source` is `automatic` or `manual`, and the call needs `iam:packages:read`. A role or group cannot be deleted while a package includes it, nor a package while someone holds it (`RESOURCE_IN_USE`). Assignments are audited as `package:assign` (with the counts, the skips, and the justification), `package:revoke`, and `package:extend`. The console lists packages, their holders, and the requests on the Access packages page. ## Self-service requests [#self-service-requests] Administrators should not be the bottleneck for routine access. Mark a package `requestable` and members ask for it themselves, from the console's Elevate page or your own UI. The approvers are emailed, decide from the same page, and approval assigns the package under their authority. ```ts title="Request and decide" const request = await iam.api.packages.request(memberCredential, { tenantId, packageId: vendorProfile.id, expiresAt: Date.parse('2027-03-31T00:00:00Z'), justification: 'Statement of work SOW-17', }); const waiting = await iam.api.packages.listApprovals(approverCredential, { tenantId }); await iam.api.packages.approveRequest(approverCredential, { tenantId, requestId: request.id, note: 'Approved for the contract term', }); ``` ### Asking [#asking] `packages.request({ tenantId, packageId, expiresAt?, justification? })` records a request for the signed-in member. It needs `iam:packages:request` on the package and the member's own ordinary session of the tenant (not an assumed role, not impersonation). The end-date and justification rules are the same as for assigning. * The request waits for the tenant's `approvalLifetimeMs` (one day by default, from the [access policy](/docs/guides/privileged-access/elevation#tenant-access-policy)), but never beyond the end it asks for. * Asking for a package the person already holds, or already asked for, fails with `CONFLICT`. Asking for a package that is not requestable fails with `INVALID_TRANSITION`. * A request that names approvers but would reach none (an empty approver group, or `managerApproval` without an active manager) is refused with `INVALID_TRANSITION`, rather than waiting for nobody. * `packages.cancelRequest({ tenantId, requestId })` withdraws one's own pending request, for example when the need went away. Grant `iam:packages:request` through a group every member belongs to, as with `iam:bindings:activate`. ### Deciding [#deciding] * `packages.approveRequest({ tenantId, requestId, expiresAt?, note? })` grants a request: it assigns the package to the requester under the approver's authority, until the end the requester asked for unless the approver chooses another. The approver therefore needs the same rights as `assign` (`iam:bindings:create` on each role, `iam:groups:update` on each group). * `packages.denyRequest({ tenantId, requestId, note? })` refuses a request, with an optional note explaining why. * Both need `iam:packages:approve` on the package. When the package names approvers, the decider must also be a member of the approver group or, with `managerApproval`, the requester's manager. Root always may. * Nobody decides on their own request, and decisions cannot be made from an impersonation session (`IMPERSONATION_RESTRICTED`). Two email templates keep people informed. `package-request` goes to the named approvers when a request is recorded, so they can decide. `package-decided` goes to the requester when the request is approved or denied. ### Request lifecycle [#request-lifecycle] * Tightening a package (turning `requestable` off, requiring a justification, or capping the duration) cancels the pending requests that no longer fit, with the reason as the note. * A direct `packages.assign` marks the person's pending request for the package approved, and an automatic assignment cancels it. * `packages.listRequests` reports a lapsed request as `expired` before the purge worker marks it. Offboarding cancels pending requests. ### Views [#views] | Call | Who | What it shows, and when to use it | | ---------------------------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`packages.listMine`](/docs/reference/api/packages#listmine) | Members | Requestable packages with the member's status on each (held, awaiting approval, or open to request), plus their assignments and recent requests. Build a "Request access" screen from it. | | [`packages.listApprovals`](/docs/reference/api/packages#listapprovals) | Approvers | Requests awaiting them, limited to packages they hold `iam:packages:approve` on. Build an approvals inbox from it. | | [`packages.listRequests`](/docs/reference/api/packages#listrequests) | Administrators | Requests filtered by `packageId`, `identityId`, and `status`; needs `iam:packages:read`. Use it to audit who asked for what. | The request flow is audited as `package:request`, `package:request-approved`, `package:request-denied`, and `package:request-cancelled`. The [lifecycle events](/docs/guides/events/lifecycle-events) page lists their metadata. ## Automatic assignment [#automatic-assignment] Some packages should simply follow the directory: everyone in engineering gets the engineering kit, every service account gets the integration profile. A package with an `autoAssign` rule is given to every active identity that matches it and taken away from automatic holders that stop matching. Rules run under their owner's authority, are re-applied when identities change and on a schedule, and hold back unusually large changes until someone confirms them. - [Automatic assignment](/docs/guides/privileged-access/automatic-assignment): Rule language, ownership, reconciliation, grace periods, and the safety brake. ## Packages as code [#packages-as-code] Packages can live in a reviewed [configuration document](/docs/guides/privileged-access/config-as-code) with the rest of the access model. They are carried by name; who holds a package is runtime state and never synced. ```json title="tenant.json (excerpt)" { "version": 1, "packages": [ { "name": "Engineering onboarding", "roles": ["Reader"], "groups": ["Engineering"], "maxDurationMs": 31536000000, "requireJustification": true, "requestable": true, "approverGroup": "Platform team", "managerApproval": false } ] } ``` ## Packages from role mining [#packages-from-role-mining] You may not know which sets of access belong together. [Role mining](/docs/guides/governance/usage-and-mining) finds role combinations many people hold together (`bundle` suggestions). Grant them as one package with `packages.create`, optionally assigned automatically by attribute. The console's Role mining page has a Create package button for this. ## Next steps [#next-steps] - [Automatic assignment](/docs/guides/privileged-access/automatic-assignment): Give a package to everyone who matches a rule. - [Access report](/docs/guides/privileged-access/access-report): See which package assignments end soon. - [Packages API](/docs/reference/api/packages): Every packages method with its signature. # Access report (/docs/guides/privileged-access/access-report) > One document with what ends soon, what is elevated now, and which keys nobody uses, plus digest emails to owners and expiry reminders. End dates and just-in-time roles only help if someone keeps an eye on them. A contractor whose account ends tomorrow may need an extension today; an administrator activation that has been live all week deserves a question; an API key nobody has used in months should probably be revoked. The *access report* puts that picture in one document per organization. Two scheduled jobs deliver it for you: the *access digest* emails it to the organization's owners, and *expiry reminders* tell each person what of theirs ends soon, so they can ask for an extension before they lose access. ## The report [#the-report] `reports.access` builds the report for one organization. Use it for a Reports page, or run it nightly and post it to a chat channel or ticketing system. The console shows it under Reports, and the CLI prints it with `better-iam report --tenant ID`. It covers: * **Identities** ending within the window, including ones already past their deadline that the purge worker has not disabled yet. * **Grants** ending within the window: temporary bindings, and temporary group memberships. Future-dated bindings starting within the window are listed too, so new access does not surprise anyone. * **Elevation**: live just-in-time activations with their justification, and the number of activation requests waiting for approval. * **API keys** unused for `unusedForMs` (including keys never used) and keys ending within the window. **API:** ```ts const report = await iam.api.reports.access(credential, { tenantId, withinMs: 30 * 86400_000, // look 30 days ahead (the default) unusedForMs: 30 * 86400_000, // keys unused for 30 days (the default) }); if (report.omitted.length) console.warn('Sections hidden from this caller:', report.omitted); ``` **CLI:** ```sh BETTER_IAM_TOKEN=... better-iam report --config better-iam.config.mjs --tenant TENANT_ID --within-days 30 --unused-days 30 ``` The report needs `iam:identities:read`. The binding and key sections additionally need `iam:bindings:read` and `iam:credentials:read`. Sections the caller may not read are left out and named in `omitted` rather than failing the whole call, so a directory administrator without `iam:credentials:read` still gets the identity section. An activation or request counts only while it could still grant: its binding is live and eligible, its holder is active, and, for a group binding, the holder is still a member. The CLI acts as the session or API key in `BETTER_IAM_TOKEN`, so each run is authorized and audited like any other call. ## The access digest [#the-access-digest] Most organizations will not open a Reports page every morning. `iam.sendAccessDigest()` (CLI `digest`) does the routing: every active organization whose report has findings gets it emailed to its owners, at most once per 20 hours. Organizations with nothing to report get no email. **API:** ```ts const result = await iam.sendAccessDigest({ withinMs: 30 * 86400_000, unusedForMs: 30 * 86400_000 }); // result.sent: [{ tenantId, recipients, expiringIdentities, expiringBindings, startingBindings, // expiringMemberships, activations, pendingRequests, unusedKeys, expiringKeys }] // result.skipped: { inactive, recent, quiet, noOwners } ``` **CLI:** ```sh better-iam digest --config better-iam.config.mjs --within-days 30 --unused-days 30 better-iam outbox --config better-iam.config.mjs ``` * It is a deployment operation for schedulers: it needs no credential and includes every report section. * `tenantId` limits it to one organization, and `minimumIntervalMs` (20 hours by default) sets how often one organization may be emailed. * Each active owner with an email address receives an `access-digest` email. Its payload carries the finding counts and the full report as JSON (`report`), so your email template can summarize it or attach it. * Organizations are skipped when they are not active, were emailed within the interval (`recent`), have nothing to report (`quiet`), or have no owner with an email (`noOwners`). * Each digest is recorded as `tenant:access-digest` (actor `deployment-operator`) with the recipients and the finding counts. * It needs the configured `sendEmail` callback (`DELIVERY_REQUIRED` otherwise). The emails are queued in the delivery outbox, so run `outbox` (or `iam.auth.dispatchOutbox()`), which delivers queued messages, afterwards. ## Expiry reminders [#expiry-reminders] Owners are not the only ones who need to know. `iam.sendExpiryReminders()` (CLI `remind`) tells the people themselves: everyone whose account, direct role bindings, group memberships, or package assignments end within the window (seven days by default) gets one `expiry-reminder` email listing them. They can then ask for an extension before they are locked out. **API:** ```ts const result = await iam.sendExpiryReminders({ withinMs: 7 * 86400_000 }); // result.sent: [{ tenantId, identityId, items }], result.skipped: { inactive, quiet } ``` **CLI:** ```sh better-iam remind --config better-iam.config.mjs --within-days 7 ``` * Each item is reminded once per end date, so extending the access brings a fresh reminder when the new end comes into the window. * The `expiry-reminder` payload carries `count`, `earliest`, and `items` as JSON with the `kind` (`account`, `role`, `group`, or `package`), `name`, and `expiresAt` of each. * A package's own bindings and memberships are reminded as the package. Eligible bindings are not reminded, and group bindings are left to the owners' digest. * Each reminder is recorded as `identity:expiry-reminder` on the person (actor `deployment-operator`) with the item keys, which is how the job knows not to remind anyone twice. * `withinMs` ranges from one minute to a year. Like the digest, it needs no credential, needs a `sendEmail` callback, and relies on the outbox for delivery. Schedule it daily beside the digest. ## Scheduling [#scheduling] | Job | Call | CLI | Suggested cadence | Why | | ------------------- | --------------------------- | -------- | ----------------- | ----------------------------------------------------- | | Report to a channel | `reports.access` | `report` | nightly | A shared view for the team that runs access. | | Owner digest | `iam.sendAccessDigest()` | `digest` | daily | Owners hear about findings without looking. | | Personal reminders | `iam.sendExpiryReminders()` | `remind` | daily | People ask for extensions before they are locked out. | See [scheduling](/docs/guides/governance/scheduling) for the other governance jobs and [background jobs](/docs/operations/jobs) for how to run them. - [reports.access](/docs/reference/api/reports#access): Signature and result type. - [CLI: report, digest, remind](/docs/reference/cli#report): Flags and defaults. # Automatic assignment (/docs/guides/privileged-access/automatic-assignment) > Birthright access with package rules that grant a package to every matching identity and remove it from holders who stop matching. Much access follows directly from who someone is: everyone in engineering needs the engineering tools, every service account needs the integration profile, everyone with a verified company address needs the intranet. This is birthright access. Granting it by hand means tickets for every joiner, and people who change teams keep what they no longer need. A *package rule* automates it. You attach a rule (`autoAssign`) to an access package. The package is then given to every active identity that matches the rule and taken away from automatic holders who stop matching. Joiners get their access without a ticket, and movers lose what no longer fits. ```ts title="Everyone in engineering gets the engineering kit" await iam.api.packages.update(credential, { tenantId, packageId: engineeringKit.id, autoAssign: { include: [{ StringEquals: { 'principal.kind': 'user', 'principal.department': 'engineering' } }], graceMs: 14 * 86400_000, }, }); ``` Set a rule with `autoAssign` on `packages.create` or `packages.update`. ## Write a rule [#write-a-rule] A rule is `{ include, exclude?, graceMs?, maxGrants?, maxRemovals? }`. It uses the policy [condition language](/docs/guides/authorization/conditions): each clause is a condition block, and an identity matches when any `include` clause matches (clauses are ORed). A matching `exclude` clause overrides that, and types are strict. ```ts title="Rule examples" // Everyone in engineering (people, not service accounts) { include: [{ StringEquals: { 'principal.kind': 'user', 'principal.department': 'engineering' } }] } // Direct members of a group { include: [{ ArrayContains: { 'identity.groups': '' }, StringEquals: { 'principal.kind': 'user' } }] } // Every service account { include: [{ StringEquals: { 'principal.kind': 'service' } }] } // A verified email domain, except contractors, and except two named people { include: [{ StringEqualsIgnoreCase: { 'identity.emailDomain': 'acme.com' }, Bool: { 'identity.emailVerified': true } }], exclude: [{ Bool: { 'principal.contractor': true } }, { StringEquals: { 'principal.id': ['id1', 'id2'] } }], } ``` ### Keys a rule may test [#keys-a-rule-may-test] A rule describes the person, never a session or a request, so it can only test facts about the identity: | Key | Type | Meaning | | ------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------- | | `principal.id` | string | The identity ID, for naming specific people. | | `principal.kind` | string | `user` for people, `service` for service accounts. | | `principal.owner` | boolean | Whether the identity is an owner of the tenant. | | `principal.` | declared | Any attribute declared in `permissions.identityAttributes` (such as `department`), meaning exactly what it means in policies. | | `identity.email` | string | The email address. | | `identity.emailDomain` | string | The email domain, lower-case. | | `identity.emailVerified` | boolean | Whether the address is verified. | | `identity.managerId` | string | The manager's identity ID, for "everyone who reports to X". | | `identity.groups` | array | The person's group memberships that no access package created. | | `identity.teams` | array | The IDs of the [teams](/docs/guides/teams-and-departments) the person belongs to and of every team above them. | | `identity.departments` | array | The ID of the person's department and of every department above it. | `identity.groups` leaves out package-created memberships so rules never chain onto another package or keep themselves alive, and a rule may not name a group its own package grants. `principal.groups` is refused. For the same reason a team membership that team sync copied from a group counts in `identity.teams` only while the person is in that group by a membership no package created. ```ts title="Everyone in Engineering and its sub-departments, and everyone in the SRE team" { include: [ { StringEquals: { 'principal.kind': 'user' }, ArrayContains: { 'identity.departments': engineeringId } }, { StringEquals: { 'principal.kind': 'user' }, ArrayContains: { 'identity.teams': sreTeamId } }, ], } ``` ### Rule constraints [#rule-constraints] * A missing attribute never satisfies an operator, negated ones included. Test absence with `Exists: { 'principal.department': false }`. * There are no request or resource keys, no policy variables, and no IP operators. `DateBefore` and `DateAfter` apply to declared string attributes. * Keys that describe a session or a grant are refused, because a rule is evaluated without any session: for example `principal.mfa`, `principal.mfaTime`, `principal.authTime`, `principal.roles`, and `principal.sessionTags.*`. * Empty clauses are refused. "Everyone" is spelled out, for example `Exists: { 'principal.id': true }`. * A rule is at most 16 384 bytes. ### Preview before saving [#preview-before-saving] A rule can grant access to hundreds of people at once, so look before you save. `packages.previewAutoAssign({ tenantId, packageId?, autoAssign?, sample? })` evaluates a stored or candidate rule without changing anything: * it lists the keys you may use, with their types and operators; * it counts who `matching` the rule, who is `excluded`, and who is `frozen` (disabled or expired), with a `sample` of matches and the clause each matched by (`matchedBy`); * for an existing package, it shows what a run would change (`plan`, `changes`) and whether the [safety brake](#safety-brake) would trip; * it warns when a clause does not test `principal.kind` (it then also matches service accounts) or tests an email without `identity.emailVerified`: administrator-set and SCIM-provisioned addresses are unverified. ## Authority and ownership [#authority-and-ownership] A rule grants access without anyone clicking "assign", so it must never grant more than a real administrator could. Each rule therefore has an *owner*: whoever last set it or changed the package's roles or groups. * The owner needs everything assigning by hand needs: `iam:packages:assign`, `iam:bindings:create` on each role, `iam:groups:update` on each group, the right to use the authorities behind the groups' bindings, and a grant authority. Role sessions and impersonation are refused. * Automatic bindings are issued under the owner's grant authority, bounded by its chain, and every run re-checks the owner's status, authority, and rights before adding anything. * When the owner is disabled, offboarded, or demoted, or their authority is revoked, the rule is *suspended* (`package:auto-suspended`). Nobody new is added, people who stop matching are still removed, and bindings under a revoked authority stop granting at once. * Another administrator takes over by saving the rule again. The holders' bindings are then re-issued under the new authority. > **Own production rules through a service account.** Own production rules through a managed service account and configuration as code, and transfer rules before offboarding their owner. ## When reconciliation runs [#when-reconciliation-runs] Reconciliation compares every rule with the directory and makes the changes: assigning the package to new matches and removing it from holders who stopped matching. It runs: * right after `identities.create`, `createMany`, `update`, and `setStatus`, and `serviceAccounts.create` and `update`, so a new or changed identity gets its packages at once; * right after team and department changes made through their APIs: members added, removed, re-timed, approved, or leaving; teams created, moved, re-synced, or deleted; people placed, moved, imported, or unassigned; departments moved or deleted; and `departments.syncManagers`; * after a rule is saved, up to 200 changes (the response carries the `reconcile` result); * on demand with `packages.reconcile({ tenantId, packageId?, confirm?, limit? })`, which administrators use to apply a rule now (console: Reconcile now); * as the scheduler job `iam.reconcilePackages()` or CLI `better-iam reconcile`. The scheduled job is required, not optional: SCIM provisioning, invitations, federated sign-in attributes, group membership changes, and expiry reach package rules only through it. Each change is its own transaction, and a second run over an unchanged directory writes nothing. ```sh title="Every 15 minutes, after purge" better-iam reconcile --config better-iam.config.mjs --fail-on-attention ``` The CLI applies at most `--limit` (1000) changes per organization per run; `truncated: true` means run it again. `--tenant` and `--package` scope it. It prints the result as JSON: * `assigned`, `refreshed`, `restored`, `ending`, and `revoked` count the changes made; * `stale` counts planned changes skipped because the directory moved in between (they are retried next run); * `failed`, `suspended`, and `braked` list what needs a person's attention. `--fail-on-attention` exits non-zero (`RECONCILE_ATTENTION`) when a change failed, was held back, or a rule is suspended, so your scheduler can alert. The command is a deployment operation: it needs no credential and no email transport. ## Removal and grace [#removal-and-grace] People who change teams often need a few days to hand over. By default an automatic holder who stops matching loses the package at the next run. With `graceMs` (at most 90 days) the assignment, and exactly the records it created, end at a set time instead: * the person gets the [expiry reminder](/docs/guides/privileged-access/access-report#expiry-reminders); * access ends at that time even if no run happens; * matching again before then restores it; * the purge worker sweeps it afterwards. Only what the assignment created is removed. ## Interplay with manual assignments [#interplay-with-manual-assignments] Rules and administrators can both assign the same package. The rules for how they meet: * A rule never touches manual assignments. `packages.assign` turns an automatic assignment into a manual one (`replacedAutomatic: true`). * `packages.revoke` and `packages.extend` refuse automatic assignments while the rule exists. To take the package away from one person, exclude them in the rule instead. * `maxDurationMs` cannot be combined with a rule. `requireJustification` is satisfied with "Automatic: matches the package rule". * A pending request is cancelled when the package is assigned automatically. * Disabled and expired identities are frozen: never assigned or removed by a rule. Offboarding and deletion remove their assignments. * A separation-of-duties conflict in prevent mode skips that identity (see [separation of duties](/docs/guides/authorization/separation-of-duties)). It is reported in `failed`, audited once, and retried later, and never fails a run or an identity update. * A record removed by hand comes back while the person matches, so certify the rule rather than the binding. * Content changes update every automatic holder, while manual holders keep what they were given. * Clearing the rule (`autoAssign: null`) removes the automatic holders at the next run, or keeps them as manual assignments with `keepAutomaticAssignments: true` (at most 5000). * A package with holders cannot be deleted, and a group, team, or department a rule names cannot be deleted. ## Safety brake [#safety-brake] A mistyped attribute value can make a rule match everyone, or nobody. So that one bad edit cannot grant or remove access across the whole organization unattended, unattended runs (the scheduler, and `packages.reconcile` without `confirm`) hold back more than `maxGrants` (100) new grants or `maxRemovals` (25) removals per package, and record `package:auto-braked`. Someone with the rights to assign the package by hand reviews the change and releases it for a day: **API:** ```ts await iam.api.packages.reconcile(credential, { tenantId, packageId, confirm: true }); ``` **CLI:** ```sh better-iam reconcile --config better-iam.config.mjs --tenant TENANT_ID --package PACKAGE_ID --confirm ``` The console offers the same as "Confirm held changes". A save through the API pre-approves its own planned counts for a day, because the person saving just saw them; configuration apply does not. A rule that no longer validates (an attribute declaration removed from the deployment, a group gone) is suspended with nothing assigned or removed. ## Configuration as code [#configuration-as-code] Rules can live in a [configuration document](/docs/guides/privileged-access/config-as-code). Documents carry `autoAssign` with group names (not IDs) in `identity.groups`, team slugs in `identity.teams`, and department names in `identity.departments`. Omitting `autoAssign` leaves the rule alone and `null` removes it. Owner, revision, and approval are runtime state and never synced, and apply does not reconcile: run `better-iam reconcile` afterwards. ## Audit [#audit] Rules act on their own, so their events are how you see what they did. Subscribe a webhook to `package:auto-*` and alert on the ones with outcome `deny`: they need a person. | Event | Actor | What happened, and why you would care | | ------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `package:auto-rule` | The administrator | A rule was set, changed, taken over, re-authored by a contents change, or cleared. Review rule changes like code changes. | | `package:auto-confirm` | The confirmer, or `deployment-operator` | Held-back changes were approved for a day. This is the human sign-off on a large change. | | `package:auto-assign` | `deployment-operator` | The reconciler assigned, refreshed, or restored an automatic assignment. Trigger downstream onboarding. | | `package:auto-ending` | `deployment-operator` | An automatic holder stopped matching and the grace period started. | | `package:auto-revoke` | `deployment-operator` | The reconciler removed an automatic assignment. Trigger downstream deprovisioning. | | `package:auto-failed` | `deployment-operator` | A change could not be applied, such as a separation-of-duties conflict; once per new problem. | | `package:auto-suspended` | `deployment-operator` | A rule stopped adding access, for example because its owner left. Someone must take it over. | | `package:auto-resumed` | `deployment-operator` | A suspended rule runs again. | | `package:auto-braked` | `deployment-operator` | An unattended run held back more grants or removals than the rule allows. Review and confirm, or fix the rule. | The [lifecycle events](/docs/guides/events/lifecycle-events) page lists the metadata of each. # Configuration as code (/docs/guides/privileged-access/config-as-code) > Export, plan, and apply a tenant's roles, policies, groups, teams, bindings, packages, agents, and guardrails as one reviewed document. An access model built by clicking through a console has three problems. Nobody reviews a change before it takes effect, staging and production slowly drift apart, and when something goes wrong there is no history of who changed what and why. *Configuration as code* solves them the way infrastructure as code does. The `config` API group treats a tenant's access model (roles, policies, groups, group bindings, access packages, guardrails, and terms of use) as one JSON document keyed by name. You keep the document in version control, review changes as pull requests, apply the same file to staging and production, and let a pipeline fail when someone changed production by hand. People are not part of the document. Identities and their direct bindings are runtime state that changes every day, so they stay out of it. ## The workflow [#the-workflow] ### Export [#export] `config.export` (`iam:config:read`, CLI `config-export`) writes a tenant's current model as a document. Use it once to start from what you have, or to copy staging's model to production. ### Review [#review] Commit the document and review changes as pull requests like any other code. ### Plan [#plan] `config.plan` (CLI `config-plan`) compares the document with the tenant and lists every change it implies without writing anything: `create`, `update` (with the differing `fields`), `delete`, and `unchanged`. Read it before you apply, the way you read a Terraform plan. ### Apply [#apply] `config.apply` (`iam:config:apply`, CLI `config-apply`) makes the tenant match the document, in one transaction, and is audited as `config:apply`. ### Guard against drift [#guard-against-drift] In CI, `config-plan --fail-on-drift` exits non-zero when production no longer matches the reviewed file, so a hand edit is noticed the same day. **API:** ```ts title="Staging to production" const document = await staging.api.config.export(credential, { tenantId: stagingTenant }); const plan = await production.api.config.plan(credential, { tenantId: productionTenant, config: document, prune: true, }); console.log( plan.summary, // { create, update, delete, unchanged } plan.changes.filter((change) => change.action !== 'unchanged'), ); await production.api.config.apply(credential, { tenantId: productionTenant, config: document, prune: true, }); ``` **CLI:** ```sh BETTER_IAM_TOKEN=... better-iam config-export --config better-iam.config.mjs --tenant TENANT_ID --output tenant.json BETTER_IAM_TOKEN=... better-iam config-plan --config better-iam.config.mjs --tenant TENANT_ID --input tenant.json --prune BETTER_IAM_TOKEN=... better-iam config-apply --config better-iam.config.mjs --tenant TENANT_ID --input tenant.json --prune ``` ## The document [#the-document] A document has `version: 1` and any subset of these kinds. Each kind is a list of items matched by name rather than ID, so the same file works in every environment even though IDs differ: | Kind | Contents | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `resourceTypes` | Tenant-defined resource types (with `permissions.mode: 'tenant-defined'`). `actions` are verbs. | | `policies` | Named policy documents. | | `roles` | Attached `policies` by name, plus a `permissions` list or an inline `document` (not both), and `inherits` by name. | | `groups` | Groups with optional `members` as emails. When present, membership is made to match exactly. | | `bindings` | Group to role, with every eligibility field (`eligible`, `maxActivationMs`, `requireJustification`, `requireMfa`, `requireApproval`, `approverGroup`, `managerApproval`) and `window`. | | `accessPolicy` | The organization's [activation floors](/docs/guides/privileged-access/elevation#tenant-access-policy). `{}` clears them. | | `departments` | The [org chart](/docs/guides/teams-and-departments) by `name` (matched ignoring case), with `code`, `description`, `parent` (a department name), `head` (an email), `costCenter`, and optional `members` as emails (when present, the department's people are made to match exactly). Needs `iam:departments:manage`. | | `teams` | [Teams](/docs/guides/teams-and-departments) by `slug` (derived from `name` when left out), with `name`, `description`, `parent` (a team slug), `department` (a department name), `joinPolicy` (`closed` by default, or `request`), `memberManagement` (`maintainers` by default, or `admins`), `maintainers` and `members` as emails, `roles` by name (the team's standing role bindings, made to match exactly when present), and `syncGroups` (the groups team sync copies members from). Needs `iam:teams:*`. | | `packages` | Access packages naming their `roles`, `groups`, and optional `autoAssign` rule, with `maxDurationMs`, `requireJustification`, `requestable`, `approverGroup`, and `managerApproval`. | | `agreements` | [Terms of use](/docs/guides/governance/agreements) members must accept: `name`, `content`, `url`, `required` (default true), `reacceptAfterDays`. | | `invariants` | [Access invariants](/docs/guides/governance/change-safety#access-invariants), guardrails such as "contractors never deploy": `name`, `description`, `subject`, `action`, `resource`, `expect`, `mode` (default `monitor`), `assumeMfa` (default true). | | `agents` | [AI agents](/docs/guides/ai-agents) by name, with `sponsor` (an email), `description`, `purpose`, `model`, `provider`, `url`, `protocols`, `delegable`, `maxDelegatedSessionSeconds`, and `boundary`. Needs `iam:agents:*`. | | `inferenceModels` | The tenant's own [models](/docs/guides/inference), with `provider` by name, `upstreamModel`, prices, `tier`, limits, `fallbacks`, and `enabled`. Needs the `inference` option and `iam:inference:manage`. | | `inferenceBudgets` | Model budgets, with `subject` (`'tenant'`, `{ group }`, `{ identity: email }`, or `{ agent: name }`), `scope`, `period` (minute to month), and limits. Needs the `inference` option. | ```json title="tenant.json" { "version": 1, "policies": [ { "name": "Read documents", "document": { "version": 1, "statements": [{ "effect": "allow", "actions": ["documents:read"], "resources": ["document/*"] }] } } ], "roles": [ { "name": "Reader", "policies": ["Read documents"] }, { "name": "Editor", "permissions": ["documents:write"], "inherits": ["Reader"] }, { "name": "Production admin", "permissions": ["deployments:*"] } ], "groups": [ { "name": "Engineering", "members": ["alice@example.com", "bob@example.com"] }, { "name": "Platform team", "members": ["carol@example.com"] } ], "bindings": [ { "group": "Engineering", "role": "Editor" }, { "group": "Engineering", "role": "Production admin", "eligible": true, "maxActivationMs": 7200000, "requireJustification": true, "requireMfa": true, "requireApproval": true, "approverGroup": "Platform team" } ], "accessPolicy": { "requireMfa": true }, "invariants": [ { "name": "Contractors never deploy", "subject": { "attribute": { "name": "contractor", "value": true } }, "action": "deployments:create", "resource": { "type": "environment", "id": "production" }, "expect": "deny", "mode": "enforce" } ] } ``` ### Names instead of IDs [#names-instead-of-ids] * Roles, groups, and policies can refer to items from the same document or already in the tenant. Role inheritance is applied once every role of the document exists, so a parent and its child can be introduced together. * Group `members` are emails, matched exactly when present; an unknown member email is a validation error. * In `bindings`, `approverGroup` names the approver group. In `packages`, `approverGroup` does the same, and `autoAssign` rules list group names in `identity.groups`, team slugs in `identity.teams`, and department names in `identity.departments` (teams and departments the document creates exist before its packages are saved). * An invariant's `subject` names groups and people portably: `{ "group": "Contractors" }` (the group may be created by the same document), `{ "identity": "alice@example.com" }` (the person must exist), `{ "attribute": { "name": "department", "value": "Sales" } }`, or `{ "everyone": true }`. * Agreements and invariants are matched by name regardless of case. * A team's or department's `parent` may come from the same document or already exist in the tenant. Unknown people, roles, parents, or groups, and parent cycles, fail the plan with `INVALID_INPUT`. Teams and departments are saved before packages, because package rules name them, and with `prune` they are deleted after packages, children before parents. ### What never belongs in the document [#what-never-belongs-in-the-document] Protected owner roles and policies, identities, their direct bindings, credentials (including agents' API keys), inference provider keys, delegations, and webhooks are never part of the document. For teams, the document describes only permanent, manually added members and standing role bindings. Temporary memberships, members that team sync copied in, join requests, and eligible, windowed, scheduled, temporary, or package-created bindings are runtime state: never exported, never removed. Naming a synced or temporary member in the document makes them a permanent manual member. A team's backing group (`team:{slug}`) never appears under `groups` or `bindings`, and department members' job titles are not part of the document. Who holds a package is runtime state, and so are a package rule's owner, revision, and approval. Exports list agreements, invariants, teams, departments, and the access policy only when the tenant has some. ## How plan and apply work [#how-plan-and-apply-work] * **Omitted kinds are left alone.** A document without `groups` does not touch groups, so you can adopt configuration as code one kind at a time. * **Pruning.** With `prune: true` (CLI `--prune`), items of a listed kind that the document does not name are deleted. Use it once the file is the whole truth for a kind: removing a role from the file then removes it from the tenant. Without it, apply only creates and updates. * **Authorization.** `plan` needs `iam:config:read`. `apply` needs `iam:config:apply` and authorizes each change like the direct call (`iam:roles:create`, `iam:groups:update`, `iam:bindings:delete`, `iam:tenants:update` for the access policy, `iam:agreements:manage`, `iam:invariants:manage`, and so on) under the caller's grant authority. * **All or nothing.** A refusal or a validation error (an unknown action, a resource type still referenced by a role, an unknown member email) rolls everything back. So does a separation-of-duties conflict ([separation of duties](/docs/guides/authorization/separation-of-duties)) or an [enforced invariant](/docs/guides/governance/change-safety#access-invariants) the apply would newly break. * **Invariants use the old rules.** Enforced invariants are checked around the whole apply against the invariants as they were before it, so relaxing an invariant and making the change it forbade take two applies. * **Agreements.** A changed agreement `content` publishes a new version, so everyone accepts the new text. Other agreement edits keep acceptances. * **Package rules.** Omitting `autoAssign` leaves a rule alone and `null` removes it. Apply does not reconcile and does not pre-approve large changes: run `better-iam reconcile` afterwards. * **Audit.** The apply is recorded as `config:apply` with `prune`, the change summary, and `changed` (one entry per change). ## Drift checks in CI [#drift-checks-in-ci] Sooner or later someone fixes an incident by editing production directly. A drift check makes sure that edit is noticed, and then either added to the file or reverted. `config-plan --fail-on-drift` prints the plan and exits non-zero with `CONFIG_DRIFT` when anything would be created, updated, or deleted, which turns it into a CI check or a nightly job. After an apply, run `check-invariants --fail-on-broken`, which evaluates the tenant's [access invariants](/docs/guides/governance/change-safety#access-invariants), so a pipeline also stops when a guardrail is broken. ```yaml title=".github/workflows/iam.yml (excerpt)" - name: Production matches the reviewed file run: better-iam config-plan --config better-iam.config.mjs --tenant "$TENANT_ID" --input tenant.json --fail-on-drift env: BETTER_IAM_TOKEN: ${{ secrets.BETTER_IAM_TOKEN }} ``` All three commands act as the session or API key in `BETTER_IAM_TOKEN`, so they are authorized and audited exactly like the console. `config-export` writes to standard output, or with `--output` to a new file (it refuses to overwrite one). Give the pipeline a [scoped API key](/docs/guides/privileged-access/lifecycle#api-key-hygiene) of a service account that holds only the `iam:config:*` and item permissions it needs. - [config API reference](/docs/reference/api/config): Signatures for export, plan, and apply. - [CLI: config-plan](/docs/reference/cli#config-plan): Flags for export, plan, and apply. # Just-in-time elevation (/docs/guides/privileged-access/elevation) > Eligible bindings that people activate for a bounded time, with justification, MFA, approval, approver groups, and tenant-wide floors. Administrator and production roles are dangerous to hand out permanently. A stolen session of someone who holds them all the time is a stolen administrator. But taking the roles away entirely means a ticket and a wait every time someone needs them, so in practice they stay granted. Just-in-time elevation removes that trade-off. You give people an eligible binding: a record that they may take a role, which grants nothing by itself. When they need the role, they activate the binding for a limited time (an activation), optionally stating a reason, proving MFA, or waiting for a second person to approve. When the time runs out, the role is gone. Nobody carries the privilege around, and every elevation leaves a record with a reason. ## Make a role eligible [#make-a-role-eligible] Create a binding as usual and add `eligible: true` plus the rules each activation must satisfy. The subject can be a person or a group; with a group, every live member may activate it. ```ts title="Eligible bindings" // The on-call group may take the incident-response role for up to two hours, with a reason and MFA. await iam.api.bindings.create(credential, { tenantId, roleId: responder.id, subjectType: 'group', subjectId: onCall.id, eligible: true, maxActivationMs: 2 * 3_600_000, requireJustification: true, requireMfa: true, }); // Production administration also needs a second person: the platform team approves. await iam.api.bindings.create(credential, { tenantId, roleId: productionAdmin.id, subjectType: 'group', subjectId: engineers.id, eligible: true, requireApproval: true, approverGroupId: platformTeam.id, }); ``` Activation settings only make sense on eligible bindings: passing them for a standing binding fails with `INVALID_INPUT` (explicit `false` flags are accepted, so forms can send every checkbox). Use `bindings.update` to change the rules later. `bindings.update({ eligible: false })` turns the binding back into a standing one, clears its activation settings, and ends every activation of it. ## Activate [#activate] When a member needs the role, they call `bindings.activate` with the binding, how long they need it, and a reason. The result is an activation record; while it is live, the role applies. ```ts title="Elevate, work, step down" const activation = await iam.api.bindings.activate(memberCredential, { tenantId, bindingId: eligibleBinding.id, justification: 'INC-4211', durationMs: 30 * 60_000, }); // activation: { id, status: 'active', active: true, activatedAt, expiresAt, ... } // Done early? Step down so the role stops applying now. await iam.api.bindings.deactivate(memberCredential, { tenantId, activationId: activation.id }); ``` `bindings.activate` checks, in order of what most often goes wrong: * **Permission.** The caller needs `iam:bindings:activate` on the role (`iam/{roleId}`), so a tenant can scope which roles a person may activate. * **Session.** The call must come from the person's own ordinary session of the tenant. Assumed-role sessions are refused with `INVALID_INPUT`, and an administrator using impersonation to view the product as a member cannot activate for them (`IMPERSONATION_RESTRICTED`). * **Binding.** The binding must apply to the caller, directly or through a group they belong to (`ACCESS_DENIED` otherwise), and must still be eligible and live (`INVALID_TRANSITION`). * **Duration.** `durationMs` defaults to the effective maximum. It must lie between one minute and that maximum; a longer or shorter value fails with `INVALID_INPUT` rather than being shortened. * **Rules.** Without a justification when one is required, the call fails with `INVALID_INPUT` (justifications are at most 2048 characters). Without MFA when it is required, it fails with `MFA_REQUIRED`. * **One at a time.** There is one live activation per binding and person. Activating again while one is live, or while a request is still waiting, fails with `CONFLICT` (409). The result carries `status` (`active`, `pending` for a request awaiting approval, or `denied`), `active` (whether it grants right now), `activatedAt`, `expiresAt`, and the `justification`. ### While an activation is live [#while-an-activation-is-live] The role behaves exactly like a standing binding. Authorization decisions, `principal.roles` in policy conditions, reviews such as `whoCan` and `effectiveActions`, and `identities.listBindings` (which shows the `activation`) all see it. ### How an activation ends [#how-an-activation-ends] An activation stops granting at the next request when any of these happens: * It reaches its `expiresAt`. This is the normal case: nobody has to do anything. * The holder calls `bindings.deactivate`. Use it to step down when the work is done early. * An administrator calls `bindings.revokeActivation`. Use it for incident response, when someone's elevated access must end now. It needs `iam:bindings:delete` on the binding and the binding's own grant authority (or root), like deleting the binding. * The holder leaves the group, the binding or role is deleted, or eligibility is turned off. The purge worker deletes ended activations from storage later; they grant nothing in the meantime. ## Approval-gated activation [#approval-gated-activation] For the most sensitive roles, a reason and MFA are not enough: you want a second person to agree before anyone elevates. This is *two-person control*. With `requireApproval`, `bindings.activate` records a request instead of activating, and the role becomes live only when an approver says yes. ```ts title="Request and approve" const pending = await iam.api.bindings.activate(engineerCredential, { tenantId, bindingId, justification: 'CHG-88', }); // The approver sees what awaits them and decides, optionally for a different duration. const queue = await iam.api.bindings.listApprovals(platformCredential, { tenantId }); await iam.api.bindings.approveActivation(platformCredential, { tenantId, activationId: pending.id, durationMs: 45 * 60_000, note: 'Approved for the change window', }); ``` The calls involved: * `bindings.activate` with `requireApproval` returns a request with `status: 'pending'`. It lapses after the tenant's `approvalLifetimeMs` (24 hours by default) if nobody decides. * `bindings.listApprovals` shows an approver the requests they may decide on, never their own. It powers an approver's inbox and needs `iam:bindings:approve` on the tenant. * `bindings.approveActivation({ activationId, durationMs?, note? })` grants a request. The role is live from that moment for the requested duration, or for another `durationMs` the approver chooses, from one minute up to the binding's effective maximum. * `bindings.denyActivation({ activationId, note? })` refuses a request, with an optional note explaining why. * `bindings.deactivate` lets the requester withdraw their own request. It is audited as `binding:deactivate` with `cancelled: true`. Both decisions need `iam:bindings:approve` on the role. Notes are at most 2048 characters. The safeguards: * Nobody decides on their own request (`INVALID_INPUT`). * Nobody decides from an impersonation session (`IMPERSONATION_RESTRICTED`), so an administrator viewing the product as an approver cannot approve in their name. * Only a request that is still waiting can be decided, and only while its binding is still eligible (`INVALID_TRANSITION`, 409). Members see their own waiting request as `pendingActivation` in `bindings.listMine`. Administrators list requests and refusals with `bindings.listActivations({ status: 'pending' })` or `({ status: 'denied' })`. ### Approver groups and managers [#approver-groups-and-managers] By default, anyone holding `iam:bindings:approve` on the role may decide. Name the approvers when only specific people should: * `approverGroupId`: only live members of that group, or a root administrator, may decide. Use it for a platform or security team that owns production access. * `managerApproval`: the requester's manager (`Identity.managerId`) may decide as well. Use it when the person who knows the requester's work should sign off. The approvers named this way are emailed each request; the requester is never among them. A request that names approvers must reach at least one active one. When the approver group is empty and the requester has no active manager, activation fails with `INVALID_TRANSITION` (409) instead of creating a request nobody can answer. Managers are set with `managerId` on `identities.create` or `identities.update`, and SCIM can maintain it. `identities.listReports` lists a manager's reports, and offboarding hands a leaver's reports to the successor. ### Emails [#emails] When the deployment sends email, two templates keep people informed without them watching a console: * `activation-request` goes to each approver when a request is recorded, so they can decide promptly. * `activation-decided` goes to the requester when a request is approved or denied, so they know whether to start working. Both carry the activation, role, requester, justification, and decision, so your application can render them however it likes. ## Tenant access policy [#tenant-access-policy] Setting the same rules on every eligible binding is repetitive, and one forgotten binding is a gap. The tenant access policy sets minimum rules, or floors, for every eligible binding of an organization at once. A binding may be stricter than the policy, never looser, so adopting a floor later tightens existing bindings without editing them. `tenants.setAccessPolicy` sets it (console: "Elevation defaults" on the Configuration page): ```ts title="Organization-wide floors" await iam.api.tenants.setAccessPolicy(credential, { tenantId, accessPolicy: { maxActivationMs: 4 * 3_600_000, requireJustification: true, requireMfa: true, approvalLifetimeMs: 8 * 3_600_000, }, }); // accessPolicy: null clears the policy. ``` The effective rules for a binding are its own settings tightened by the policy: a flag is on when either sets it, and the maximum length is the smaller of the two. Unknown fields are refused. Setting the policy needs `iam:tenants:update` and recent authentication, and is audited as `tenant:access-policy`. The policy can also live in a [configuration document](/docs/guides/privileged-access/config-as-code) as `accessPolicy`. ## Who may activate and approve [#who-may-activate-and-approve] Activation and approval are ordinary permissions, so you decide who elevates and who approves with roles: * `iam:bindings:activate` on `iam/{roleId}` lets a member activate an eligible binding of that role. On `*` it lets them activate any role they are eligible for, and on the tenant it also allows `bindings.listMine`. * `iam:bindings:approve` on `iam/{roleId}` lets a person decide requests for that role. On the tenant it also allows `bindings.listApprovals`. The usual shape is a *Member* role bound to a group every person belongs to, and an *Approver* role bound to the approver group: ```ts title="Member and Approver roles" const member = await iam.api.roles.create(credential, { tenantId, name: 'Member', permissions: ['iam:bindings:activate'], }); await iam.api.bindings.create(credential, { tenantId, roleId: member.id, subjectType: 'group', subjectId: everyone.id, }); const approver = await iam.api.roles.create(credential, { tenantId, name: 'Approver', permissions: ['iam:bindings:approve'], }); await iam.api.bindings.create(credential, { tenantId, roleId: approver.id, subjectType: 'group', subjectId: platformTeam.id, }); ``` Because a member cannot approve their own request and the binding can name the approver group, approval gives you two-person control for the roles that need it. ## Views for members and administrators [#views-for-members-and-administrators] Each audience has a read that shows exactly what it needs: | Call | Who | What it shows, and when to use it | | -------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | [`bindings.listMine`](/docs/reference/api/bindings#listmine) | Members | Their own bindings, standing and eligible, with the live activation and any waiting request. Build an "Elevate" screen from it. | | [`bindings.listApprovals`](/docs/reference/api/bindings#listapprovals) | Approvers | Waiting requests they may decide on, with the role and requester. Build an approvals inbox from it. | | [`bindings.listActivations`](/docs/reference/api/bindings#listactivations) | Administrators | Live activations by default; requests or refusals with `status`; ended ones with `includeExpired`. Use it to see who is elevated right now. | | [`bindings.list`](/docs/reference/api/bindings#list) | Administrators | Bindings; `eligible: true` or `false` separates eligible from standing ones. Use it to audit which roles are standing. | | [`identities.listBindings`](/docs/reference/api/identities#listbindings) | Administrators | One person's effective bindings with `activation`, `pendingActivation`, and `inWindow`. Use it on a member's detail page. | `bindings.listMine` needs no `iam:bindings:read`, only `iam:bindings:activate` on the tenant, so members can see what they may elevate to without seeing everyone else's access. It must be called from an ordinary session of the tenant. The console's Elevate page is built this way: eligible roles with their rules, the activation form, pending requests, and, for approvers, the requests awaiting their decision. ## Audit and alerting [#audit-and-alerting] Every step is recorded in the audit log, so you can alert on elevation as it happens and answer "who was an administrator on Tuesday, and why?" later: | Event | What happened, and why you would care | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `iam:bindings:activate` | The activation call ran. It is the operation record every API call leaves. | | `binding:activate` | A member activated a binding and now holds the role, with `activationId`, `roleId`, `expiresAt`, and the justification. Alert on it to see every elevation. | | `binding:activation-requested` | Activation needs approval and a request was recorded. Page approvers or open a ticket. | | `binding:activation-approved` | An approver granted a request. Keep it as evidence of two-person control. | | `binding:activation-denied` | An approver refused a request. Tell the requester, or watch for repeated refusals. | | `binding:deactivate` | An activation ended early: `cancelled: true` for a withdrawn request, `revoked: true` when an administrator ended it, which often means incident response. | Subscribe a webhook to `binding:*` to alert on every elevation, or query the audit log for a member's history. The member page in the console shows it as "Activation history". ```ts title="Alert on elevation" await iam.api.webhooks.create(credential, { tenantId, url: 'https://ops.example.com/hooks/iam', events: ['binding:*'], }); ``` The access analysis points out where eligibility would help. It reports people with a direct, permanent binding to a full administration role (`standing-privileged-access`) and eligible bindings nobody activated within the dormancy window (`unused-eligible-binding`), which may not be needed at all. See [access reviews](/docs/guides/authorization/reviews). - [Lifecycle events](/docs/guides/events/lifecycle-events): Metadata of every binding event. - [bindings API reference](/docs/reference/api/bindings): Signatures and HTTP routes for activation and approval. # Privileged access (/docs/guides/privileged-access) > Keep standing privilege low and access time-bound with eligible roles, expiring identities, access packages, reports, and configuration as code. Most breaches and audit findings involve access that nobody needed any more: an administrator role granted for one incident and never removed, a contractor account that outlived the contract, an API key nobody remembers issuing. The longer powerful access sits unused, the more likely it is to be misused or stolen. The privileged access features attack that problem from two sides. They keep standing privilege (powerful access someone holds all the time) close to zero, and they put an end date on access so it goes away without anyone remembering to remove it. This section explains how the pieces fit; the [authorization guides](/docs/guides/authorization) cover the building blocks such as [roles](/docs/guides/authorization/roles) and [temporary access](/docs/guides/authorization/temporary-access). ## Standing versus eligible roles [#standing-versus-eligible-roles] A role binding connects a role to a person or group. By default a binding is *standing*: the role applies whenever the binding exists. That is right for everyday access, such as an editor role for a writer, but wrong for an administrator role that is needed a few times a month. Three properties narrow a binding without changing the role: | Property | What it does | When to use it | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | `startsAt` | Future-dates the binding. It is listed with its start date but grants nothing until then. | Access that begins on the first day of a contract. | | `expiresAt` | Makes the binding temporary. It stops granting at that instant and the purge worker deletes it. Group memberships can carry an `expiresAt` too, which ends every grant and activation the membership carried. | Projects, contracts, and anything with a known end. | | `window` | Makes the binding recurring (`{ from, to, timeZone, days? }`): it applies only inside the hours and days you name, in that time zone. | Support staff who work business hours. | An eligible binding (`eligible: true`) goes further. It records that someone *may* hold a role, but grants nothing by itself. When the person needs the role, they *activate* the binding with `bindings.activate`, and the role applies only for a bounded time: `maxActivationMs`, one hour by default and seven days at most. Each such period is an activation. Use eligibility for administrator, auditor, incident-response, and production-access roles. The person is entitled to the role but holds it only while they need it, and every activation leaves a record with a reason. ```ts title="Engineers may take the production admin role for up to two hours" await iam.api.bindings.create(credential, { tenantId, roleId: productionAdmin.id, subjectType: 'group', subjectId: engineers.id, eligible: true, // [!code highlight] maxActivationMs: 2 * 3_600_000, requireJustification: true, requireMfa: true, }); ``` [Just-in-time elevation](/docs/guides/privileged-access/elevation) covers activation, approvals, approver groups, and the organization-wide rules. ## The lifecycle at a glance [#the-lifecycle-at-a-glance] Access has a life: it starts when someone joins or takes on a task, changes as they work, and should end when they move on. Each stage has a feature that makes it automatic. * **Joining.** An access package is a named set of roles and group memberships granted together, such as an onboarding kit. [Assigning a package](/docs/guides/privileged-access/access-packages) grants the whole set in one call, a [package rule](/docs/guides/privileged-access/automatic-assignment) grants it to everyone who matches (for example everyone in engineering), and `startsAt` lets access begin on a set day. * **Working.** The everyday baseline is small. Privileged roles are eligible and activated for a bounded time, with a justification, MFA, or a second person's approval. * **Changing.** `expiresAt` ends identities, bindings, memberships, and package assignments by themselves. The [access report](/docs/guides/privileged-access/access-report) shows what ends soon, and its emails tell owners and the people themselves. * **Leaving.** [`identities.offboard`](/docs/guides/privileged-access/lifecycle#offboarding) disables a person and removes everything that granted them access in one transaction, handing what they owned to a successor. Every step is audited. The [lifecycle events](/docs/guides/events/lifecycle-events), such as `binding:activate` when someone elevates or `identity:expire` when an account runs out, can be sent to a webhook for alerting. ## In this section [#in-this-section] - [Just-in-time elevation](/docs/guides/privileged-access/elevation): Eligible bindings with activation rules, approval, approver groups, and tenant-wide floors. - [Access lifecycle](/docs/guides/privileged-access/lifecycle): Time-bound identities, temporary memberships, API key hygiene, and offboarding. - [Access packages](/docs/guides/privileged-access/access-packages): Roles and groups granted as one set, assigned by administrators or requested by members. - [Automatic assignment](/docs/guides/privileged-access/automatic-assignment): Birthright rules that give a package to everyone who matches and take it away when they stop. - [Access report](/docs/guides/privileged-access/access-report): What ends soon, what is elevated now, and which keys nobody uses, with digest and reminder emails. - [Configuration as code](/docs/guides/privileged-access/config-as-code): Export, plan, and apply the access model as one reviewed document, and fail CI on drift. ## Putting it together [#putting-it-together] A common baseline for an organization, in five steps: ### Nobody holds a standing administrator role [#nobody-holds-a-standing-administrator-role] Put everyone in an *Everyone* group and give that group a *Member* role with `iam:bindings:activate`, the permission to activate bindings one is eligible for. Add `iam:packages:request` if members may ask for access packages themselves. ```ts const member = await iam.api.roles.create(credential, { tenantId, name: 'Member', permissions: ['iam:bindings:activate', 'iam:packages:request'], }); await iam.api.bindings.create(credential, { tenantId, roleId: member.id, subjectType: 'group', subjectId: everyone.id, }); ``` ### Privileged roles are eligible [#privileged-roles-are-eligible] Bind administrator and production roles to the relevant groups as eligible, with `requireJustification` and `requireMfa`. For the most sensitive roles add `requireApproval` with a named approver group, so a second person signs off every activation. A [tenant access policy](/docs/guides/privileged-access/elevation#tenant-access-policy) can make these rules the minimum for every eligible binding at once. ### Contractors and integrations expire [#contractors-and-integrations-expire] Give contractors an `expiresAt`, so their accounts stop working on the last day of the contract. Give integrations scoped, labeled API keys whose last use you can review. See [access lifecycle](/docs/guides/privileged-access/lifecycle). ### A nightly job keeps it honest [#a-nightly-job-keeps-it-honest] Schedule three jobs. `purge` (`iam.purgeDeleted()`) disables expired identities and deletes expired grants. `report` prints the [access report](/docs/guides/privileged-access/access-report) for a channel or ticket. `config-plan --fail-on-drift` fails when production no longer matches the reviewed [configuration file](/docs/guides/privileged-access/config-as-code). When the deployment sends email, add `digest` (the report emailed to owners) and `remind` (a note to each person whose access ends soon). See [scheduling](/docs/guides/governance/scheduling). ```sh better-iam purge --config better-iam.config.mjs BETTER_IAM_TOKEN=... better-iam report --config better-iam.config.mjs --tenant TENANT_ID BETTER_IAM_TOKEN=... better-iam config-plan --config better-iam.config.mjs --tenant TENANT_ID --input tenant.json --fail-on-drift ``` ### Leavers are offboarded [#leavers-are-offboarded] When someone leaves, call `identities.offboard` with a successor. It disables the account, removes every grant, and hands the resources and reports the leaver owned to the successor. # Access lifecycle (/docs/guides/privileged-access/lifecycle) > Time-bound identities, temporary memberships, future-dated bindings, API key hygiene, and offboarding that removes all access in one call. Access should end when the reason for it ends: a contract, a project, an integration, a job. In most systems it ends only when someone remembers to remove it, which is why old accounts and keys pile up. Better IAM lets you put the end date on the access itself when you grant it, shows you what nobody uses, and gives you one call that removes everything when a person leaves. ## Time-bound identities [#time-bound-identities] Contractors, interns, auditors, and temporary integrations have a known last day. Instead of a calendar reminder to disable them, give the identity a deadline: `expiresAt` on `identities.create` or `identities.update` for people, and on `serviceAccounts.create` or `serviceAccounts.update` for service accounts. ```ts title="A contractor with an end date" await iam.api.identities.create(credential, { tenantId, email: 'contractor@example.com', name: 'Contractor', expiresAt: Date.parse('2027-03-31T00:00:00Z'), }); // Who deactivates in the next 30 days? const expiring = await iam.api.identities.list(credential, { tenantId, expiresBefore: Date.now() + 30 * 86400_000, }); // The contract was extended: move the deadline, or clear it with null. await iam.api.identities.update(credential, { tenantId, identityId, expiresAt: null }); ``` What happens at the deadline: * **Immediately.** Every credential of the identity is refused from `expiresAt` on, even before any job runs. * **At the next purge.** The retention worker, `iam.purgeDeleted()` (CLI `purge`), disables the identity, revokes its sessions, and ends its role activations. It records `identity:expire` with the `kind` and `expiresAt` (actor `deployment-operator`). Schedule it nightly; see [scheduling](/docs/guides/governance/scheduling). * **Before it happens.** `identities.list({ expiresBefore })` lists identities that end before a date, the [access report](/docs/guides/privileged-access/access-report) shows them to administrators, and [expiry reminders](/docs/guides/privileged-access/access-report#expiry-reminders) warn the person a week ahead. Clearing or extending the deadline is the only way to re-enable an expired identity, so nobody quietly turns a contractor back on. Changing the deadline of an owner or a root administrator needs the same protection as disabling them, and moving a deadline into the past is refused: disable the identity instead. > **The worker only catches up.** Expired identities and activations are refused at their next use whether or not the worker has run. The schedule only affects how quickly statuses and reports catch up. ## Temporary and future-dated grants [#temporary-and-future-dated-grants] The same idea works for individual grants, when the person stays but a piece of their access should not. These are covered in detail under [temporary access](/docs/guides/authorization/temporary-access); in short: * **Temporary bindings.** `bindings.create({ ..., expiresAt })` grants a role until a date (in the future, within ten years). `bindings.update` extends, shortens, or clears the end with `null`. * **Future-dated bindings.** `startsAt` makes a binding begin on a date. It is listed with its start but grants nothing until then, so you can set up a new hire's access before their first day. * **Temporary memberships.** `groups.addMember({ ..., expiresAt })` makes a group membership end on a date, and with it every grant and activation it carried. `groups.updateMember` extends, shortens, or clears the end, and re-adding a lapsed member renews the membership. * **Packages.** An access package assignment with an end date ends every binding and membership it created at that time. See [access packages](/docs/guides/privileged-access/access-packages). ```ts title="Project teams and start dates" // A three-month project membership that ends by itself. await iam.api.groups.addMember(credential, { tenantId, groupId: projectTeam.id, identityId: alice.id, expiresAt: Date.now() + 90 * 86400_000, }); // Access that begins on the contract's first day and ends on its last. await iam.api.bindings.create(credential, { tenantId, roleId: contractor.id, subjectType: 'identity', subjectId: bob.id, startsAt: Date.parse('2027-01-04T08:00:00Z'), expiresAt: Date.parse('2027-06-30T18:00:00Z'), }); ``` Expired grants stop applying at their end. The purge worker then deletes expired bindings, lapsed memberships, ended activations, and package assignments past their end, and marks pending access requests past their lifetime as expired, reporting each count (`expiredBindings`, `expiredMemberships`, and so on). ## API key hygiene [#api-key-hygiene] API keys let integrations call Better IAM as a service account (an identity for software rather than a person). Keys are easy to issue and easy to forget: after a year, nobody knows which ones are still used or what they may do. Three properties keep them reviewable: * **Labels.** A key carries a `name` (up to 128 characters) and a `description` (up to 512), so a review can tell what each one is for. * **Last use.** A key records `lastUsedAt` when it authenticates a request (at most once a minute). `credentials.list({ unusedForMs })` returns keys nobody used in that long, including keys never used since they were issued, so you can find the ones to revoke. * **Scopes.** `scopes` is an allowlist of actions. It is compiled into a session policy that allows exactly those actions on every resource, so an integration never holds more than it needs, whatever roles its service account has. Pass either `scopes` or a full `policy`, not both. ```ts title="Issue a labeled, scoped key and revoke unused ones" const key = await iam.api.credentials.create(credential, { tenantId, identityId: deployer.id, name: 'github-actions', description: 'Deploys from the release workflow', scopes: ['deployments:create', 'deployments:read'], expiresInSeconds: 90 * 86400, }); // key.token is returned once: store it in the CI secret store now. // Keys nobody has used for 30 days, including keys never used since they were issued. const unused = await iam.api.credentials.list(credential, { tenantId, unusedForMs: 30 * 86400_000, }); for (const item of unused) await iam.api.credentials.revoke(credential, { tenantId, credentialId: item.id }); ``` The key calls: * `credentials.create` issues a key for an active, unexpired service account (`INVALID_IDENTITY` otherwise). Keys expire after 90 days by default (`expiresInSeconds`, from one minute to one year). It needs recent authentication. * `credentials.list` lists keys with their labels, scopes, and last use; token material is never returned. * `credentials.update` relabels a key or moves its expiry (into the future, at most a year out) without changing the token. Moving the expiry needs recent authentication and the right to use the key's issuing authority. * `credentials.rotate` issues a new token for the key and invalidates the old one. It keeps the label and expiry but resets the usage history, so a rotated key shows up as unused until it is put to work. * `credentials.revoke` deletes a key at once. It needs recent authentication. The [access report](/docs/guides/privileged-access/access-report) lists unused and expiring keys, and the access analysis reports keys unused for its dormancy window as `stale-api-key` (see [access reviews](/docs/guides/authorization/reviews)). ## Offboarding [#offboarding] When someone leaves, their access is spread across role bindings, groups, packages, sessions, keys, relationships, and delegated authority. Removing it piece by piece is slow and easy to get wrong, and the things they owned still need an owner. `identities.offboard` does it in one transaction: it disables the person or service account, removes everything that granted them access, and hands what they owned to a successor. ```ts title="Offboard a leaver" const summary = await iam.api.identities.offboard(credential, { tenantId, identityId: leaver.id, reason: 'Left the company (HR-1234)', successorId: manager.id, // takes over the resources and reports the leaver had }); // summary: { identity, sessions, bindings, memberships, activations, packages, relationships, // accessRequests, authorities, resourcesReassigned, resourcesOwned, reportsReassigned } // Later, after your retention period, remove the record itself: await iam.api.identities.delete(credential, { tenantId, identityId: leaver.id }); ``` In that one transaction the call: 1. Removes ownership (the protected Owner binding) when the identity is an owner. 2. Ends its role activations. 3. Revokes its [access package](/docs/guides/privileged-access/access-packages) assignments, with the bindings and memberships they created, including automatic ones. 4. Deletes its remaining role bindings (under the caller's authority, like `bindings.delete`) and group memberships. 5. Deletes its relationships, and cancels its pending access requests and package requests. 6. Revokes the grant authorities it holds, so grants it issued as a delegated administrator stop applying. 7. Moves its reports to the successor, or leaves them without a manager when there is no successor. A successor who reported to the leaver moves up to the leaver's own manager, and a report that would close a reporting loop is left without a manager. 8. Transfers ownership of the managed resources it owns to the successor (`resourcesReassigned`), or reports them as `resourcesOwned` when there is no successor. 9. Ends every session and API key of the identity. 10. Disables the identity and records `identity:offboard` with the `reason`, `kind`, `successorId`, and the counts. The result counts everything it removed, which makes a good offboarding record for auditors. The rules: * It needs `iam:identities:update` and recent authentication. `reason` is required (up to 512 characters). * Nobody can offboard themselves. Root administrators are protected, the last owner is protected, and only an owner or root can offboard an owner. * The successor must be another active identity of the tenant. * The identity stays as a disabled record, so the audit trail still names who they were. `identities.delete` tombstones it later, after your retention period. The console offers offboarding on the member page. [Package rules](/docs/guides/privileged-access/automatic-assignment) never assign anything to a disabled identity, so a leaver who still matches a rule does not get access back. > **Transfer package rules first.** Revoking a leaver's grant authorities also suspends any [package rule](/docs/guides/privileged-access/automatic-assignment#authority-and-ownership) they own. Have another administrator take over their rules before offboarding them. - [identities.offboard](/docs/reference/api/identities#offboard): Signature and result fields. - [Access lifecycle recipes](/docs/guides/recipes/access-lifecycle): Copy-ready calls for contractors, keys, and leavers. # Configuration reference (/docs/operations/deployment/configuration) > Every top-level betterIam() option, grouped by area, with what it controls, its default, when you would change it, and the rule enforced at startup. Everything a Better IAM instance does is decided by the options object you pass to `betterIam()`. Most options have safe defaults, so a development instance needs only a database, a secret, and a URL. Production deployments usually adjust a handful more: delivery callbacks, the client-IP hook, session lifetimes, and metrics. The configuration is validated once, when `betterIam()` runs. Anything out of range throws an `IamError` with code `INVALID_CONFIG` before the instance exists, so a misconfigured deployment fails at startup instead of on the first request. Durations are in milliseconds unless the name ends in `Seconds` or `Days`. ```ts title="Where each group of options lives" betterIam({ database, secret, previousSecrets, baseURL, basePath, trustedOrigins, // core authentication, http, // sign-in and transport hierarchy, permissions, onboarding, tenantDefaults, domains, // tenancy and catalog hosts, regions, // sign-in addresses and regions inference, a2a, billing, // AI models, agent cards, spend resolveResource, resolveContext, // application integration accessRequests, accessUsage, // access workflows events, auditArchive, observability, // events, audit, telemetry plugins, protocols, sts, // extensions and credentials }); ``` ## Core [#core] The core options tell the instance where its data lives (the storage adapter), which secret protects it, and where it is reachable. Every deployment sets the first four. ## Authentication [#authentication] `authentication` configures how people sign in, how sessions behave, and how messages reach them. It accepts every `AuthOptions` field except `store`, `secret`, `previousSecrets`, `baseURL`, and `trustedOrigins`, which come from the top level. Its `onAudit` and `deliverWebhook` hooks are wired by the server itself, so use [`events.onEvent`](#events-and-audit) and `events.deliverWebhook` instead. ### Sign-in methods and delivery [#sign-in-methods-and-delivery] These options decide which sign-in methods exist at all, and how email and SMS leave the system through the outbox. Tenants can restrict methods further with their own policy, but never enable one the deployment turned off. ### Sessions, devices, and notifications [#sessions-devices-and-notifications] These options bound how long a sign-in lasts and what people are told about activity on their account. Tenant policies can shorten the lifetimes, never extend them. ### Rate limits [#rate-limits] `authentication.rateLimits` bounds how fast anyone can guess credentials. Counters are durable and shared by every instance through the database by default. A refusal is `RATE_LIMITED` (429) with `retryAfterMs` in the body and a `Retry-After` header. ### Password policy [#password-policy] `authentication.passwordPolicy` screens every password the deployment accepts, wherever it is set. Passwords always use Argon2id and need at least 12 characters; tenant policies add their own rules on top of these. ## HTTP [#http] The `http` options shape how the handler treats the browser: which client details it records and how its cookies behave. ## Tenancy and catalog [#tenancy-and-catalog] These options describe your product's shape: which kinds of tenants exist, which actions and resource types policies can mention, how people join, and what every new tenant starts with. ### Application integration [#application-integration] These two callbacks connect authorization to your own data. They run as trusted server code on every decision that needs them. ## Access workflows [#access-workflows] These options tune self-service access requests and the usage tracking behind role mining. ## Events and audit [#events-and-audit] These options decide where audit events go after commit: to your code, to webhooks, and to an independent archive of the audit chain. ## Observability [#observability] These options expose timing and outcomes of everything the instance does. See [Observability](/docs/operations/observability) for span fields and metric names. ## Extensions and protocols [#extensions-and-protocols] These options add code to the instance: plugins that extend the API, and raw protocol handlers. ## AI models, agent cards, and billing [#ai-models-agent-cards-and-billing] These options turn on and tune the features for AI agents and spend tracking. Each has its own guide with the full settings. ## Temporary credentials [#temporary-credentials] `sts` controls temporary credentials: how long role sessions and session tokens may last, whether session tokens can be signed JWTs that services verify offline, and whether workloads (CI, Kubernetes, cloud functions) may exchange their own OIDC tokens for role sessions. Invalid values throw `INVALID_CONFIG` naming the field, such as `sts.maxRoleSessionSeconds must be an integer from 900 to 43200`. ### Session JWT signing [#session-jwt-signing] `sts.jwt` holds the keys that sign session JWTs and the rotation window for retired keys. ### Web-identity federation [#web-identity-federation] `sts.webIdentity` governs the OIDC token exchange, including the safety limits on fetching each provider's keys. ## Validation rules at a glance [#validation-rules-at-a-glance] Construction fails with `INVALID_CONFIG` when: * `database` is missing, or `secret` is shorter than 32 characters; * `previousSecrets` lists more than five values, repeats one, includes one shorter than 32 characters, or includes `secret`; * `baseURL` is not HTTPS outside localhost, 127.0.0.1, and \[::1], or `basePath` is malformed or ends with a slash; * a trusted origin is not an exact origin, or the passkey RP ID does not match every trusted origin; * `requireEmailVerification` or `passwordlessEmail` is on without `sendEmail`, `passwordlessSms` is on without `sendSms`, or `failedSignInAlerts` is set without `sendEmail`; * a session, idle, recent-authentication, trusted-device, rate-limit, delivery-attempt, access-request, webhook-timeout, archive, or `sts` value is outside the ranges above; * `http.cookieSameSite` is not `lax` or `strict`, or `http.persistentCookies` is not a boolean; * the hierarchy has no `root`, names an undefined or `root` child, or sets `maxDepth` outside 1 to 100; * `tenantDefaults` fails tenant limit or authentication policy validation; * plugins repeat an id, or an endpoint uses an unregistered action, a method other than POST, a duplicate or malformed path, or lacks a validator or handler. Startup validation cannot judge everything. `doctor` catches the rest: a placeholder-like secret, a short metrics token, or no email transport. See [Doctor](/docs/operations/storage#doctor). ## Next steps [#next-steps] - [Deployment](/docs/operations/deployment): The options every production instance sets, and the deploy sequence. - [Secrets and keys](/docs/operations/deployment/secrets): Rotating `secret` with `previousSecrets` without signing anyone out. - [Scheduled jobs](/docs/operations/jobs): The workers that deliver the outbox and expire access. # Database operations (/docs/operations/deployment/database) > Migrations, transactions, durability, indexes, upgrades, backups, and PostgreSQL integration checks for the database behind Better IAM. Better IAM keeps all of its state in one table of your database, `iam_records`, as JSON documents grouped by collection. Relationships between records are enforced by the services under transaction isolation, not by foreign keys, so the rule for operators is simple: change IAM data only through the API, the CLI, or the storage adapter contract. Raw SQL writes can violate integrity and revocation guarantees. ## Migrations [#migrations] Each release may need new tables or indexes, and old records may need backfilling. Run migrations deliberately, as a step of every deploy, before the new release serves traffic: ```sh better-iam migrate --config better-iam.config.mjs ``` `migrate` calls `iam.initialize()`, which is idempotent. It: 1. applies the built-in schema steps that have not run yet, each recorded by name in the `iam_migrations` table; 2. runs every plugin's `migrate` callback in its own transaction; 3. starts the retention window of tenants deleted before retention timestamps existed; 4. chains audit events recorded before the audit chain existed, in one transaction. The first `initialize()` after upgrading to a chained version backfills the whole log, so on very large logs run it in a maintenance window. | Step | What it creates | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0001_records` | The `iam_records` table (primary key collection and id, unique collection, tenant, and natural key) and a tenant index. | | `0002_query_indexes` | Tenant and natural-key indexes, plus the lookup indexes: one partial expression index per hot field on SQLite and libSQL, one `jsonb_path_ops` GIN document index on PostgreSQL. | | `0003_ordered_indexes` | Tenant-scoped indexes on `timestamp` and `sequence` for ordered audit reads. | | `0004_expiry_indexes` | Collection-wide indexes on `expiresAt`, `deliveredAt`, and `failedAt` for the retention sweep. | | `0005_lookup_indexes` | `sourceSessionId` and `trustId` indexes on SQLite and libSQL for session cascades (PostgreSQL's document index already covers them). | A released step never changes; new fields get a new step. The schema version itself lives in `iam_schema_version`, and a database with an unsupported version is refused with `SCHEMA_VERSION`. ### Upgrading an existing database [#upgrading-an-existing-database] > **Plan a maintenance window for index migrations.** `0002_query_indexes` and `0004_expiry_indexes` build their indexes inside the migration transaction. On large tables that blocks IAM writes, but not reads, for the duration. * On PostgreSQL, `0002_query_indexes` first rewrites existing rows that need the value encoding described below. * A migration waits up to ten minutes for another instance's migration to finish. * Instances of the previous release keep working during a rolling upgrade. On PostgreSQL they would read encoded values raw, which only matters for records holding U+0000 or unpaired surrogates. * `doctor` reports `schema-behind` (an error) whenever the database lacks a step of the running release. ## Transactions and consistency [#transactions-and-consistency] Security checks such as "is this one-time token still unused" only work if no other request can change the answer between the check and the write. All adapters therefore serialize IAM transactions: SQLite takes an immediate writer lock, libSQL takes the writer lock (`BEGIN IMMEDIATE`) before any read, and PostgreSQL uses a transaction-scoped advisory lock that also serializes other adapter instances. Check-and-write operations such as token consumption and last-owner protection depend on this, so every instance that touches these tables must go through the adapter. * **No network calls inside transactions.** Avoid them in your own transactional code too. External side effects belong in the outbox or after commit; delivery callbacks already run outside the write transaction. * **No distributed cache to invalidate.** Token validity and the current role and policy state are read on every use, so a revocation takes effect on the next request everywhere. * **Session activity is throttled.** Validating a session updates its `lastSeenAt` at most once a minute, or once per tenth of the idle timeout when that is shorter. Busy clients do not turn every request into a write, and idle expiry stays accurate to that interval. ## Durability [#durability] A revocation that is lost in a crash gives access back, so committed IAM writes must survive power loss. The defaults keep them durable: **SQLite:** File databases run in write-ahead-log mode with `synchronous = FULL` by default: readers in other processes never block the writer, and a committed transaction survives power loss. * `sqliteAdapter({ journalMode: 'delete' })` restores the rollback journal, for example on network file systems that cannot share WAL memory. * `durability: 'normal'` trades the last transactions before a power failure for faster commits. In WAL mode that never corrupts the database. * With `journalMode: 'delete'`, keep `durability: 'full'`: a power failure at NORMAL can corrupt a rollback-journal database. `doctor` reports this combination as the error `sqlite-durability`. **PostgreSQL:** Keep `synchronous_commit` on. With `synchronous_commit = off`, a crash can lose recently committed transactions, including revocations, and `doctor` reports the warning `postgres-async-commit`. **libSQL:** Local libSQL files behave like SQLite files. Remote Turso and sqld databases queue write transactions on the server, and embedded replicas keep a local file in sync with a remote URL. Durability of remote data is the server's. An in-memory database (`:memory:`) is reported by `doctor` as the warning `in-memory-database`: everything is lost when the process exits. ## Queries and indexes [#queries-and-indexes] IAM looks records up on every request, so these lookups must stay fast as the data grows. Record lookups run in SQL. Scalar filter fields become typed JSON conditions, and results are paged in SQL whenever the whole filter can be expressed there. Hot lookup fields are indexed: session token hashes, identity and group ids, email addresses, OAuth artifact hashes, and the rest of `INDEXED_FIELDS` in `better-iam/core`. SQLite and libSQL use one partial expression index per field; PostgreSQL uses a single `jsonb_path_ops` GIN index over the document plus B-tree indexes on tenant and natural key. Audit listings, exports, and retention pruning page through events in timestamp or sequence order in SQL (`IamStore.findOrdered`), so their cost follows the page size rather than the length of the log. * **PostgreSQL pending list.** Rows written after the GIN index exists wait in its pending list until a vacuum merges them. Autovacuum does this in normal operation. After a bulk import, run `VACUUM ANALYZE iam_records` so lookups use the index immediately. * **PostgreSQL value encoding.** `jsonb` cannot hold U+0000 or unpaired surrogates, so the PostgreSQL adapter stores such strings, and object keys, in a reversible encoding (`encodeJsonbDocument` in `better-iam/core`) and decodes them on every read. Applications see the original values. Filters on such values are evaluated in memory. * **libSQL write cost.** `@libsql/client` compiles each statement on every call, and SQLite compile time grows with the number of indexes an insert maintains. Local libSQL writes therefore cost more than the SQLite adapter's, which caches prepared statements. ## Retention [#retention] Sign-ins, OAuth and SAML flows, and deliveries leave records behind after they stop mattering. Without a sweep, storage and some scans grow with traffic (`dispatchOutbox`, for example, reads the whole outbox). Schedule `sweep` and `purge`; see [Scheduled jobs](/docs/operations/jobs#retention-sweep) for exactly what each deletes and what is never deleted by age. ## Backups [#backups] * **Back up the database** with your usual tooling, and test restores. Records are only consistent as a whole, so restore a whole database, never individual rows. * **Back up the keys with it.** The deployment `secret` (and any `previousSecrets`) opens authenticator secrets, webhook secrets, and queued deliveries. A restored database without its secret has enrolled factors and pending messages nobody can read. OAuth and SAML keys need the same care. * **Portable snapshots.** `better-iam store-export` writes every record to a JSON Lines file in one consistent transaction, and `store-import` loads it into an empty database. A snapshot holds credential hashes and encrypted secrets, so protect it like the database itself. See [Storage adapters](/docs/operations/storage#snapshots-and-moving-between-databases). * **Keep audit history independently.** Archive each tenant's audit chain outside the database with [`audit-archive`](/docs/operations/jobs#continuous-audit-archiving), so a restored or tampered database cannot rewrite what was already archived. ## Measuring [#measuring] `instrumentStore(store, onCall)` from `better-iam/core` wraps any store and reports every read, write, `collections`, and `describe` call, with its collection, filter keys (never values), record count, and duration. Use it for slow-query logs and capacity planning. ```ts import { instrumentStore } from 'better-iam/core'; import { postgresAdapter } from 'better-iam/adapter-postgres'; const database = instrumentStore( postgresAdapter({ connectionString: process.env.DATABASE_URL! }), (call) => { if (call.durationMs > 50) logger.warn('slow IAM storage call', call); }, ); ``` `pnpm bench:scale` seeds a deployment of `BENCH_IDENTITIES` identities (default 5000) and prints per-operation latency and storage calls over `BENCH_ITERATIONS` runs of each operation (default 40). ## PostgreSQL integration checks [#postgresql-integration-checks] The PostgreSQL-only tests run against a real server: ```sh BETTER_IAM_POSTGRES_URL=postgres://localhost/better_iam_test pnpm test:postgres ``` * Point `BETTER_IAM_POSTGRES_URL` at an isolated test database. Tests use namespaced records and separate pools. * The adapter conformance suite gives each case its own schema when the server honors the connection `options` parameter, and falls back to per-case collection prefixes otherwise. `BETTER_IAM_POSTGRES_POOL_SIZE` sets its pool size (default 3). * The normal suite explicitly skips PostgreSQL-only cases when the variable is absent. CI runs a dedicated PostgreSQL service job. ## Next steps [#next-steps] - [Storage adapters](/docs/operations/storage): Adapter options, snapshots between databases, and every doctor finding. - [Scheduled jobs](/docs/operations/jobs): The purge and sweep jobs that keep storage from growing with traffic. - [Secrets and keys](/docs/operations/deployment/secrets): The keys to back up together with the database. # Sign-in addresses and regions (/docs/operations/deployment/hosts-and-regions) > Give every organization its own sign-in address (a subdomain or a verified custom hostname), pin requests on it to that organization, and serve each organization from its home region. Every AWS account has its own sign-in URL, `123456789012.signin.aws.amazon.com`, and every Slack workspace its own address, `acme.slack.com`. An address of their own tells people they are signing in to the right place, lets the sign-in page show the organization's name and rules before anyone types, and keeps one organization's browser session away from another's. Enterprise customers go further and want the sign-in page on their own domain, `login.acme.com`. And once customers sit on several continents, each organization should be served from the region that holds its data. Better IAM supports all three: * **Organization subdomains.** With `hosts.patterns`, each organization with an alias is reachable at an address built from it, such as `acme.signin.example.com`, or with its region, `acme.signin.eu-west-1.example.com`. * **Custom hostnames.** Organizations verify hostnames they control with a DNS record, and one can become their primary address. * **Regions.** With `regions`, each organization has a home region, and sign-in for it is sent there. A request that arrives on an organization's address is **pinned** to that organization, whichever form the address takes. ## Organization subdomains [#organization-subdomains] ### Configure the pattern [#configure-the-pattern] A pattern is a hostname template. `{tenant}` stands for an organization's alias (its [slug](/docs/reference/api/tenants#setslug)), and `{region}` for one of your regions. The first pattern is the canonical one, used in sign-in URLs and email links. ```ts title="iam.ts" export const iam = betterIam({ database, secret: process.env.BETTER_IAM_SECRET!, baseURL: 'https://signin.example.com', hosts: { patterns: ['{tenant}.signin.example.com'], signInPath: '/login', // where your sign-in page lives on each address }, }); ``` Then point the addresses at your application, which serves the IAM handler on every host: 1. A wildcard DNS record, `*.signin.example.com`, to your load balancer or edge. 2. A wildcard TLS certificate for `*.signin.example.com`. 3. Your application (Next.js, Express, and so on) answering on all of those hosts, with the IAM handler mounted as usual. Nothing per organization needs to be deployed. Patterns are checked when the instance is built. A pattern must contain `{tenant}` exactly once and be a valid hostname template; `{region}` needs the `regions` option; outside `localhost` the base URL must be HTTPS; and when passkeys are enabled, every pattern must sit under the passkey domain (the RP ID), because a passkey only works on its own domain and the domains under it. ### What pinning does [#what-pinning-does] On `acme.signin.example.com` (or Acme's verified custom hostname), every request acts for Acme: | Request | What happens | | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | | A sign-in call without `tenantId` (`auth.signIn`, magic links, passkeys, password resets, invitations) | It acts in Acme. The page does not need to know Acme's tenant ID. | | A sign-in call naming another organization's `tenantId` | Refused with `HOST_MISMATCH`. | | A session, API key, or role session of another organization | Refused with `HOST_MISMATCH`, for every authenticated call. | | A page on one organization's address calling the API on another's | Refused with `HOST_MISMATCH`. | | The browser's `Origin` is an organization address | Trusted, like the deployment's own origins, so the page may call the API. | | An address whose alias no active organization holds | `NOT_FOUND`, so a suspended organization's address stops working at once. | The session cookie is host-only (the `__Host-` prefix forbids a `Domain` attribute), so a session started on Acme's address is never even sent to Globex's. The deployment's own address (`baseURL`) is not pinned and serves every organization, as before; root administrators sign in there. ```ts title="A sign-in page on acme.signin.example.com" const client = createIamClient(); // The address names the organization: show its name, then sign in to it. const org = await client.tenants.lookup({ host: location.host }); await client.auth.signIn({ tenantId: org.tenantId, email, password }); ``` Over plain HTTP the `tenantId` may be left out on an organization's address, and the handler fills it in. The typed client asks for it because its types mirror the server API, where the deployment's own address serves every organization. ### Development and proxies [#development-and-proxies] `localhost` subdomains resolve to your own machine in every modern browser, so the same setup works in development without DNS: use `baseURL: 'http://localhost:3000'` and the pattern `'{tenant}.localhost:3000'`, then open `http://acme.localhost:3000`. Behind a reverse proxy that rewrites `Host`, set `hosts.forwardedHost: true` to read the address from `X-Forwarded-Host` instead. Enable it only when your own proxy sets that header, because the address decides which organization a request is pinned to. ## Custom hostnames [#custom-hostnames] Enterprise customers often want the sign-in page on their own domain. With `hosts.customHostnames`, an organization can verify a hostname it controls, and from then on that hostname works exactly like its subdomain. ```ts title="iam.ts" hosts: { patterns: ['{tenant}.signin.example.com'], customHostnames: true, cnameTarget: 'custom.signin.example.com', // what organizations point their hostname at }, ``` ### Claim the hostname [#claim-the-hostname] An administrator of the organization calls [`hostnames.add`](/docs/reference/api/hostnames#add). It returns two DNS records: a TXT record that proves control, and a CNAME that sends traffic to your deployment. ```ts const claimed = await iam.api.hostnames.add(credential, { tenantId, hostname: 'login.acme.com' }); // TXT _better-iam-challenge.login.acme.com "better-iam-hostname=…" // CNAME login.acme.com custom.signin.example.com ``` ### Publish the records and verify [#publish-the-records-and-verify] The organization adds both records with its DNS provider, then calls [`hostnames.verify`](/docs/reference/api/hostnames#verify). It answers `verified: false` until the TXT record is visible, so a settings page can poll it. A verified hostname belongs to exactly one organization. ### Serve a certificate [#serve-a-certificate] Your TLS termination needs a certificate for the new hostname. With on-demand certificates, ask Better IAM whether a hostname is allowed. It says yes only for the deployment's own hosts, active organizations' subdomains, and verified custom hostnames. ```ts title="An on-demand TLS check (Caddy's ask endpoint)" app.get('/tls/allowed', async (request, response) => { const allowed = await iam.hosts.allowed(String(request.query.domain)); response.status(allowed ? 200 : 404).end(); }); ``` ### Make it the primary address (optional) [#make-it-the-primary-address-optional] [`hostnames.setPrimary`](/docs/reference/api/hostnames#setprimary) makes the hostname the organization's canonical address: sign-in URLs and email links use it instead of the subdomain. The deployment's own addresses cannot be claimed: its base URL, its trusted origins, and its whole subdomain space (`HOSTNAME_NOT_ALLOWED`). A passkey is bound to one domain, so passkey sign-in is refused on a custom hostname outside the passkey domain (`FEATURE_DISABLED`); people sign in there with a password, a magic link, or single sign-on, and keep using their passkeys on the subdomain. ## Regions [#regions] A multi-region deployment runs the same application in several regions, for example one in the United States and one in the European Union. Each deployment knows its own region, and each organization has a **home region**: where it signs in, and where its sessions are issued. ```ts title="iam.ts (the eu-west-1 deployment)" export const iam = betterIam({ // ... regions: { current: 'eu-west-1', regions: { 'us-east-1': { label: 'United States', baseURL: 'https://signin.us-east-1.example.com' }, 'eu-west-1': { label: 'Europe', baseURL: 'https://signin.eu-west-1.example.com' }, }, }, hosts: { patterns: ['{tenant}.signin.{region}.example.com'] }, }); ``` An organization's home region is its own `region`, set with `tenants.create({ region })` or later with [`tenants.setRegion`](/docs/reference/api/tenants#setregion) (root administrators only), or else its nearest ancestor's. Projects follow their organization. An organization created directly under the root without a region is homed where it was created. Organizations that existed before you configured regions have none, and every region serves them until you set one. ### Sign-in is sent to the home region [#sign-in-is-sent-to-the-home-region] Every entry point to sign-in checks the organization's home region: * `tenants.lookup` (by alias or by address) * `domains.discover` (by email) * public sign-in calls naming its tenant * requests on its address Everywhere but the home region they answer `WRONG_REGION` with HTTP 421 Misdirected Request. The error carries the `region` and, when one can be built, a `location`: the organization's sign-in URL in its own region. An address that names the wrong region (`acme.signin.eu-west-1.example.com` for an organization homed in `us-east-1`) is redirected the same way. ```ts title="A global sign-in page" try { const org = await client.tenants.lookup({ slug: form.organization }); location.assign(org.signInUrl ?? `/login?org=${org.slug}`); } catch (error) { if (error instanceof IamClientError && error.code === 'WRONG_REGION' && error.location) location.assign(error.location); // the organization lives in another region else throw error; } ``` ### One database or one per region [#one-database-or-one-per-region] Regions can share one database (a globally replicated PostgreSQL, for example) or each keep their own: | | Shared database | A database per region | | ------------------------------------------------ | -------------------------------------------- | ------------------------------------------------------------- | | Where organization data lives | Everywhere the database replicates | Only in its home region | | How a region finds another region's organization | From the organization's record | `regions.locate(alias)`, your own small directory | | Moving an organization | `tenants.setRegion` | Copy its data to the new region first, then `setRegion` there | | Creating an organization in another region | `tenants.create({ region })` from any region | On that region's deployment only | With separate databases, a region does not know other regions' organizations at all. `regions.locate` lets it answer `WRONG_REGION` for their aliases and addresses instead of `NOT_FOUND`. Keep that directory tiny: an alias and a region per organization, for example in a global key-value store. ```ts regions: { current: 'eu-west-1', regions: { 'us-east-1': {}, 'eu-west-1': {} }, locate: async (alias) => directory.get(`org:${alias}`), // 'us-east-1' | 'eu-west-1' | undefined }, ``` > **Regions route sign-in; they do not move data.** The region check decides where sign-in is served and where sessions are issued. Authenticated calls are not refused by region, so administrators can still manage every organization from one place when the database is shared. For strict data residency, give each region its own database. ## Sign-in URLs and email links [#sign-in-urls-and-email-links] Every organization has one canonical sign-in URL: its primary custom hostname, else its subdomain from the first pattern, else its region's `baseURL`, followed by `hosts.signInPath`. You get it from: * `iam.hosts.signInUrl(tenantId)` on the server; * `signInUrl` in the results of `tenants.lookup` and `domains.discover`; * `signInUrl` on every email and SMS message your `sendEmail` and `sendSms` callbacks receive. That last one means invitations, password resets, and magic links can open on the organization's own address. The built-in renderer passes it to your link builders: ```ts title="Links that open on the organization's address" sendEmail: async (message) => { const rendered = renderDeliveryMessage(message, { appName: 'Acme Cloud', links: { invitation: ({ token, signInUrl }) => `${signInUrl ?? appUrl}/join?token=${token}`, passwordReset: ({ token, signInUrl }) => `${signInUrl ?? appUrl}/reset?token=${token}`, magicLink: ({ token, signInUrl }) => `${signInUrl ?? appUrl}/magic?token=${token}`, }, }); if (rendered) await mailer.send({ to: message.to, ...rendered }); }, ``` ## Framework integrations [#framework-integrations] The Next.js integration (`@better-iam/next`) and the Express, Hono, Fastify, and SvelteKit integrations (`@better-iam/middleware`) call the handler in process from server actions, loaders, and route handlers. When the instance has organization addresses, those calls keep the host the visitor is on, so they are pinned exactly like browser calls. A server action on `acme.signin.example.com` signs people in to Acme without passing its tenant ID. In your own middleware, `iam.hosts.resolve(host)` tells you which organization an address belongs to: undefined for the deployment's own and unknown hosts, `NOT_FOUND` for an address no active organization holds, and `WRONG_REGION` when another region serves it. ```ts const org = await iam.hosts.resolve(request.headers.get('host') ?? ''); if (org) response.locals.organization = org; // { tenantId, name, slug, via: 'pattern' | 'custom' } ``` ## Options [#options] `iam.hosts` exposes `region` (this deployment's), `resolve(host)`, `signInUrl(tenantId)`, and `allowed(hostname)` for the checks above. ## Next steps [#next-steps] - [Custom hostnames API](/docs/reference/api/hostnames): Claim, verify, make primary, and release organization hostnames. - [Tenants and identities](/docs/guides/concepts/tenants-and-identities): Aliases, the tenant tree, and how organizations are isolated. - [Configuration](/docs/operations/deployment/configuration): Every other option of the instance. # Deployment (/docs/operations/deployment) > Runtime requirements, the betterIam() options every production instance sets, environment variables, and the deploy sequence with the CLI. A Better IAM deployment is your application process plus a database. There is no separate service to run: you construct one instance with `betterIam()`, mount its HTTP handler, run migrations at deploy time, and schedule a few worker jobs. This page covers the runtime, the options you must decide on, and the deploy sequence. The [configuration reference](/docs/operations/deployment/configuration) lists every option. ## Runtime [#runtime] * **Node.js 22.12** or a newer supported LTS release. The CI matrix covers Node 22 and 24 on Windows and Linux. * **Database drivers:** each storage adapter bundles its driver. SQLite uses a native driver (`better-sqlite3`), libSQL uses `@libsql/client` (local files, embedded replicas, and remote Turso or sqld databases), and PostgreSQL uses `pg`. * **No edge runtimes** for the complete authentication and protocol server. The OAuth issuer in particular needs Node's HTTP interfaces (see [protocol mounts](/docs/operations/deployment/protocol-mounts)). ## What every production instance sets [#what-every-production-instance-sets] Most options have safe defaults, but a few describe your deployment and have no sensible default. Configuration is validated once, when `betterIam()` runs, and invalid values throw `INVALID_CONFIG`. These are the options you cannot leave to defaults in production: Protocol keys are separate, explicit inputs: the OAuth provider's signing JWKs, its 32-byte encryption key and cookie keys, SAML signing and decryption keys, and the session-JWT keys in `sts.jwt`. No production signing key is ever generated inside a request handler, so supply each one from your secret store and keep it stable across replicas and restarts. ```ts title="lib/iam.ts" import { betterIam } from 'better-iam'; import { postgresAdapter } from 'better-iam/adapter-postgres'; import { sendEmail, sendSms } from './delivery'; export const iam = betterIam({ database: postgresAdapter({ connectionString: process.env.DATABASE_URL! }), secret: process.env.BETTER_IAM_SECRET!, // Only during a secret rotation: the old value(s), comma-separated. previousSecrets: process.env.BETTER_IAM_PREVIOUS_SECRETS?.split(',').filter(Boolean), baseURL: 'https://identity.example.com', trustedOrigins: ['https://app.example.com'], authentication: { appName: 'Acme Cloud', sendEmail, sendSms, passkeys: { rpID: 'example.com', rpName: 'Acme Cloud' }, }, http: { // Trust this header only because your own proxy sets it. clientInfo: (request) => ({ ip: request.headers.get('x-real-ip') ?? undefined, userAgent: request.headers.get('user-agent') ?? undefined, }), }, observability: { metrics: { bearerToken: process.env.METRICS_TOKEN } }, }); ``` ## Environment variables [#environment-variables] Better IAM reads configuration from your code, not from the environment. The variables below are the ones the CLI, the generated starter configuration, and the test suites use. | Variable | Used by | Purpose | | --------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `BETTER_IAM_SECRET` | Starter configuration | The deployment `secret` (at least 32 characters). | | `BETTER_IAM_PREVIOUS_SECRETS` | Starter configuration, console | Comma-separated secrets being rotated out (`previousSecrets`). | | `BETTER_IAM_BASE_URL` | Starter configuration | The `baseURL` (defaults to `http://localhost:3000`). | | `DATABASE_URL` | Starter configuration (`postgres`) | The PostgreSQL connection string. | | `BETTER_IAM_DATABASE` | Starter configuration (`sqlite`) | The SQLite file (defaults to `./better-iam.db`). | | `BETTER_IAM_DATABASE_URL`, `BETTER_IAM_DATABASE_TOKEN` | Starter configuration (`libsql`) | The libSQL URL (defaults to `file:./better-iam.db`) and auth token. | | `BETTER_IAM_ROOT_EMAIL`, `BETTER_IAM_ROOT_NAME`, `BETTER_IAM_ROOT_PASSWORD` | `bootstrap`, `recover-root` | The root administrator to create. The name defaults to "Root administrator". | | `BETTER_IAM_TOKEN` | `config-*`, `analyze`, `report`, `mine-roles`, `check-invariants` | The session token or API key the command acts as. | | `BETTER_IAM_POSTGRES_URL`, `BETTER_IAM_POSTGRES_POOL_SIZE` | `pnpm test:postgres` | An isolated test database and the conformance pool size (default 3). | | `BENCH_IDENTITIES`, `BENCH_ITERATIONS` | `pnpm bench:scale` | Seeded identities (default 5000) and runs per operation (default 40). | ## Deploy sequence [#deploy-sequence] The same few steps run the first time you deploy and, minus the one-time ones, on every release after that: ### Create the configuration (first time only) [#create-the-configuration-first-time-only] `better-iam init --database sqlite|postgres|libsql` writes a starter `better-iam.config.mjs` that reads the variables above. It refuses to overwrite an existing file. The CLI loads the default export of this trusted `.mjs` file as executable JavaScript: an options object, a factory returning one, or an instance already created with `betterIam()`. ### Migrate [#migrate] `better-iam migrate` (or `iam.initialize()`) creates or upgrades the schema, runs plugin migrations, and backfills anything older releases left behind. Run it deliberately on every deploy, before the new release serves traffic. It is idempotent. See [Database operations](/docs/operations/deployment/database). ### Bootstrap the root (once) [#bootstrap-the-root-once] `better-iam bootstrap` creates the root tenant and its first root administrator, the account from which all administration starts. It runs only against an uninitialized installation. Supply `BETTER_IAM_ROOT_EMAIL`, `BETTER_IAM_ROOT_NAME`, and `BETTER_IAM_ROOT_PASSWORD` through the environment; passwords are never accepted on the command line. The result contains the root tenant and identity IDs and `mfaEnrollmentRequired: true`: enroll MFA before the account is used, because root authority requires an MFA session. ### Check the deployment [#check-the-deployment] `better-iam doctor --strict` connects, reports the schema, storage settings, and findings, and exits non-zero (`DOCTOR_FINDINGS`) on any error or warning, which makes it a deployment gate. ### Start the application and the workers [#start-the-application-and-the-workers] Mount `iam.handler` (Fetch) or `iam.nodeHandler` (Node) under `basePath` (default `/api/iam`) and start the [scheduled jobs](/docs/operations/jobs). If you use the OAuth provider, Shared Signals, or SCIM outbound, also run their [protocol jobs](/docs/operations/jobs#protocol-jobs) in the application process. ```sh better-iam init --database postgres --config better-iam.config.mjs better-iam migrate --config better-iam.config.mjs better-iam bootstrap --config better-iam.config.mjs better-iam doctor --config better-iam.config.mjs --strict ``` `better-iam recover-root` uses the same three environment variables to create a new root administrator in the root tenant when nobody can sign in as root any more. It is recorded as `root:recover`, and the new account must enroll MFA too. Treat access to the configuration, the database credentials, and the ability to run `recover-root` as root-equivalent. ## The CLI [#the-cli] The `better-iam` CLI is how operators and schedulers act on a deployment without writing code: setting it up, running the recurring jobs, checking the audit log, moving data, and rotating secrets. It loads the same configuration file your application uses, so it always sees the same database and secret. Every command is listed with its flags in the [CLI reference](/docs/reference/cli). Most commands are **deployment operations**: they work on storage directly and need no credential, which is why the configuration file and database credentials must be protected like root. The tenant administration commands instead act as the session or API key in `BETTER_IAM_TOKEN`, so they are authorized and audited exactly like the same call from the console. Two commands, `init` and `audit-verify-archive`, touch no database at all. The 29 commands fall into five groups. ### Setup and health [#setup-and-health] | Command | What it does and when to use it | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | [`init`](/docs/reference/cli#init) | Writes a starter `better-iam.config.mjs` for SQLite, PostgreSQL, or libSQL. Run it once when you set up a deployment; it never overwrites a file. | | [`migrate`](/docs/reference/cli#migrate) | Creates or upgrades the schema and runs plugin migrations. Run it on every deploy, before the application starts. | | [`bootstrap`](/docs/reference/cli#bootstrap) | Creates the platform root tenant and its first root administrator from the environment. Run it once, on an empty installation. | | [`recover-root`](/docs/reference/cli#recover-root) | Creates another root administrator from the same variables, recorded as `root:recover`. Use it when nobody can sign in as root. | | [`doctor`](/docs/reference/cli#doctor) | Reports the schema, storage settings, and findings; `--strict` fails on any error or warning. Run it after deploys and as a deployment gate. | ### Scheduled jobs [#scheduled-jobs] | Command | What it does and when to use it | | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | | [`outbox`](/docs/reference/cli#outbox) | Delivers pending email, SMS, and webhook messages, then dispatches audit hooks. Run it every minute. | | [`purge`](/docs/reference/cli#purge) | Removes tenants deleted longer ago than the retention window and expires bindings, memberships, requests, and identities. Run it hourly. | | [`sweep`](/docs/reference/cli#sweep) | Deletes expired sessions, protocol artifacts, and old delivery records so storage stops growing with traffic. Run it beside `purge`. | | [`reconcile`](/docs/reference/cli#reconcile) | Assigns and removes rule-based access packages (birthright access). Run it every 15 minutes, after `purge`. | | [`digest`](/docs/reference/cli#digest) | Emails each organization's owners its access report when there is something to report. Run it daily. | | [`remind`](/docs/reference/cli#remind) | Emails people whose account or access ends within a week. Run it daily, beside `digest`. | | [`close-certifications`](/docs/reference/cli#close-certifications) | Closes and applies auto-closing certification campaigns past their due date. Run it hourly or daily. | | [`monitor-invariants`](/docs/reference/cli#monitor-invariants) | Evaluates every organization's access invariants and records breaks and restorations, so webhooks can alert. Run it hourly. | | [`audit-archive`](/docs/reference/cli#audit-archive) | Copies new audit events, verified, to the configured archive. Run it every few minutes. | [Scheduled jobs](/docs/operations/jobs) explains each job in detail. ### Audit [#audit] | Command | What it does and when to use it | | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`audit-verify`](/docs/reference/cli#audit-verify) | Recomputes one tenant's audit hash chain from storage and fails when it does not verify. Use it for spot checks and during incidents. | | [`audit-export`](/docs/reference/cli#audit-export) | Writes one tenant's audit chain as JSON Lines to a new file, for a manual archive or an investigation. | | [`audit-prune`](/docs/reference/cli#audit-prune) | Deletes events older than `--retention-days` (365) and leaves a checkpoint so the rest still verifies. Use it to enforce audit retention after archiving. | | [`audit-verify-archive`](/docs/reference/cli#audit-verify-archive) | Verifies one tenant's archive directory written by `createJsonlAuditArchive`, without the database or a configuration file (it takes `--directory` and `--tenant`). Use it to prove the archive is complete and untampered. | ### Storage and secrets [#storage-and-secrets] | Command | What it does and when to use it | | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | | [`store-export`](/docs/reference/cli#store-export) | Writes every record to a JSON Lines snapshot. Use it for portable backups and migrations. | | [`store-import`](/docs/reference/cli#store-import) | Loads a snapshot into an empty database in one transaction. | | [`store-copy`](/docs/reference/cli#store-copy) | Copies the database into another configuration's empty database, for example SQLite to PostgreSQL. | | [`rotate-secrets`](/docs/reference/cli#rotate-secrets) | Re-seals stored values with the current secret during a secret rotation; `--dry-run` only counts. | ### Tenant administration (acts as `BETTER_IAM_TOKEN`) [#tenant-administration-acts-as-better_iam_token] | Command | What it does and when to use it | | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`config-export`](/docs/reference/cli#config-export) | Writes a tenant's roles, policies, groups, resource types, and group bindings as JSON, to keep them in version control. | | [`config-plan`](/docs/reference/cli#config-plan) | Prints what applying a file would create, update, or delete; `--fail-on-drift` turns it into a CI check. | | [`config-apply`](/docs/reference/cli#config-apply) | Applies a file in one transaction; `--prune` also deletes items the file omits. | | [`analyze`](/docs/reference/cli#analyze) | Prints the tenant's access-analysis findings; `--fail-on high` fails a nightly job or a gate. | | [`report`](/docs/reference/cli#report) | Prints the access report: expiring identities and bindings, unused keys, live activations, pending requests. Pipe it into a ticket or chat channel nightly. | | [`mine-roles`](/docs/reference/cli#mine-roles) | Prints role-mining suggestions and peer outliers, for periodic role cleanup. | | [`check-invariants`](/docs/reference/cli#check-invariants) | Evaluates the tenant's invariants; `--fail-on-broken` fails CI after `config-apply`. | A failed command prints `CODE: message` and exits 1; unexpected failures print a generic message rather than internal details. Secrets are never accepted as command-line arguments. ## Next steps [#next-steps] - [Configuration reference](/docs/operations/deployment/configuration): Every option, default, and validation rule. - [Database operations](/docs/operations/deployment/database): Migrations, durability, backups, and upgrades. - [Secrets and keys](/docs/operations/deployment/secrets): Rotating the deployment secret without signing anyone out. - [Protocol mounts](/docs/operations/deployment/protocol-mounts): OAuth, SAML, and SCIM in a host application. - [Build and release](/docs/operations/deployment/releases): Checks, packing, and publication. # Protocol mounts (/docs/operations/deployment/protocol-mounts) > Mount the IAM HTTP handler and the OAuth, SAML, and SCIM protocol services in a host application, and what the handler enforces on every request. The IAM instance exposes two transports over the same router: `iam.handler(request)` for Fetch-style runtimes and `iam.nodeHandler(req, res)` for Node's HTTP server. Federation and provisioning protocols (OAuth and OpenID Connect, SAML, and SCIM) are separate services that you create with `iam.protocolHost` callbacks and attach with `iam.useProtocol(service)`. Nothing is mounted until you do so: importing the umbrella package never activates a provider. They are opt-in because each protocol is another public surface. An OAuth token endpoint, a SAML assertion consumer, or a SCIM API accepts requests from outside systems, and every one needs its own keys, trusted origins, and monitoring. An application that has no enterprise customers should not expose a SAML endpoint it never configured, so you mount exactly the protocols you use. ## Mount the handler [#mount-the-handler] The handler is how browsers, the client SDK, and identity providers reach Better IAM. Serve it under `basePath` (default `/api/iam`). Every framework integration does this for you; see [Frameworks](/docs/frameworks) for Next.js, SvelteKit, NestJS, Express, Hono, Fastify, and others. **Node:** ```ts title="server.ts" import { createServer } from 'node:http'; import { iam } from './lib/iam'; createServer((req, res) => { void iam.nodeHandler(req, res); }).listen(3000); ``` **Fetch:** ```ts title="app/api/iam/[...path]/route.ts" import { iam } from '@/lib/iam'; export const GET = (request: Request) => iam.handler(request); export const POST = (request: Request) => iam.handler(request); export const OPTIONS = (request: Request) => iam.handler(request); // Only when SCIM inbound is mounted under this path: identity providers also send these. export const PUT = (request: Request) => iam.handler(request); export const PATCH = (request: Request) => iam.handler(request); export const DELETE = (request: Request) => iam.handler(request); ``` The IAM API itself only needs `GET`, `POST`, and `OPTIONS`. Forward every method to the handler when a mounted protocol needs more, as SCIM does for updates and deletions. ## Mount protocols [#mount-protocols] Create each protocol service with `...iam.protocolHost` and its own explicit configuration, then mount it. Mounted protocols are consulted before the IAM routes, in the order you mount them. ```ts title="lib/protocols.ts" import { createOAuthLogin, createOAuthProvider } from 'better-iam/oauth'; import { createSamlService } from 'better-iam/saml'; import { createScimService } from 'better-iam/scim'; import { iam } from './iam'; const host = iam.protocolHost; // Inbound SCIM provisioning, served at /scim/v2 by default. iam.useProtocol(createScimService({ ...host })); // Tenant-managed SAML connections, served under /saml. iam.useProtocol( createSamlService({ ...host, serviceProvider: { baseUrl: 'https://identity.example', privateKey: secrets.samlSpKey, publicCertificate: secrets.samlSpCertificate, }, }), ); // OAuth and OIDC sign-in, started at /oauth/login/{connectionId}. iam.useProtocol(createOAuthLogin({ ...host, trustedOrigins: ['https://product.example'], connections })); // The OAuth/OIDC authorization server. It needs Node's HTTP interfaces: serve it through iam.nodeHandler. iam.useProtocol( createOAuthProvider({ ...host, issuer: 'https://identity.example/oidc', jwks: secrets.privateSigningJwks, cookieKeys: secrets.cookieSigningKeys, encryptionKey: secrets.base64Encoded32ByteEncryptionKey, trustedOrigins: ['https://identity.example'], interactionUrl: (uid) => `https://identity.example/interactions/${uid}`, renderDevicePage: ({ kind, form }) => renderDeviceScreen(kind, form), renderLogoutPage: ({ form }) => renderLogoutScreen(form), }), ); ``` Each service answers under its own default paths, which its `basePath` option (or, for the authorization server, its `issuer`) changes: | Service | Package | Default paths | Transport | | ----------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | SCIM 2.0 server | `better-iam/scim` | `/scim/v2/{connectionId}/Users`, `Groups`, `Bulk`, and the discovery documents; the admin JSON routes under `/scim/admin` | Fetch handler, served by either transport | | SAML service provider | `better-iam/saml` | `/saml/{connectionId}/login`, `/saml/{connectionId}/metadata`, `/saml/{connectionId}/acs`, and each configured connection's callback URL | Fetch handler, served by either transport | | OAuth and OIDC sign-in | `better-iam/oauth` | `/oauth/login/{connectionId}` and each connection's callback URL | Fetch handler, served by either transport | | OAuth/OIDC authorization server | `better-iam/oauth` | The issuer's path (for example `/oidc`) and `/.well-known/oauth-authorization-server` followed by that path | Node only: serve it through `iam.nodeHandler` | | SCIM outbound management API | `better-iam/scim` | `POST /scim/provisioning/targets/...` | Fetch handler, served by either transport | | Shared Signals transmitter metadata | `better-iam/oauth` | `/.well-known/ssf-configuration` followed by the issuer's path | Not a mounted service: call `signals.handler(request)` before `iam.handler` ([Shared Signals](/docs/federation/shared-signals#serve-the-transmitter-metadata)) | When a protocol is mounted through IAM, a successful federated sign-in sets the standard IAM session cookie. Protocol requests run with the same client details as any other request, so tenant IP allowlists, network blocks, and IP-bound sessions judge federated sessions exactly like password sign-ins. If you use a protocol package standalone instead, you apply the returned session to your own response and must implement every host callback as a trusted server function. The protocol guides cover each service's configuration: [OAuth sign-in](/docs/federation/oauth-sign-in), [OAuth provider](/docs/federation/oauth-provider), [SAML](/docs/federation/saml), [SCIM inbound](/docs/federation/scim), and [SCIM outbound](/docs/federation/scim-outbound). The [Federation overview](/docs/federation#who-configures-what) explains which parts your team configures and which ones customers manage at runtime. ### Interaction routes are yours [#interaction-routes-are-yours] The OAuth provider does not render login, consent, device, or logout screens; your application does. Those routes must: * validate the Origin and CSRF protection on every POST; * authenticate a real IAM credential (the signed-in session) and pass that credential to `issuer.completeInteraction(req, res, { credential, consent })`; * never treat account IDs or tenant IDs posted from a form as verified identity; * render the provider-generated device and logout forms unchanged, because they carry the required CSRF fields. ### Raw mounts [#raw-mounts] The `protocols` option accepts raw mounts, `{ handle?(request), nodeHandler?(req, res) }`, consulted before the IAM routes. A `handle` returns a `Response` when it answered, or `undefined` to pass. A raw `nodeHandler` sees every request, so it must return `true` (or end the response) only for paths it owns. `useProtocol` builds the same kind of mount and restricts a service's Node handler to its own `basePath`. ## What the handler enforces [#what-the-handler-enforces] You do not need to add these protections yourself. Every request through `handler` or `nodeHandler` passes the same boundary: * **Routing.** Protocol mounts answer first. Other paths outside `basePath` get a 404. `GET` serves only `basePath/health` and `basePath/metrics`; `OPTIONS` answers CORS preflights; the API itself is `POST` only. The Node transport refuses `TRACE`, `CONNECT`, and `TRACK`. * **CSRF.** JSON mutations require `Content-Type: application/json` and `X-Better-IAM: 1`. Requests carrying cookies also require an Origin header, and any Origin must exactly match `trustedOrigins` (`UNTRUSTED_ORIGIN`, 403). * **CORS.** Trusted origins receive credentials-enabled CORS headers that expose `Retry-After` and `X-Request-Id`, so browser clients can read error codes and back off. * **Body size.** IAM request bodies are capped at 64 KiB. The Node transport accepts up to 2 MiB for other paths, such as protocol requests, and answers 413 beyond that. * **Credentials.** A bearer credential takes precedence over the session cookie, so a bearer-authenticated request neither replaces nor clears the browser's cookie. * **Response headers.** Every JSON response carries `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. A valid `X-Request-Id` is echoed; see [Observability](/docs/operations/observability#request-ids). * **Cookies.** Over HTTPS the session cookie is `__Host-better-iam.session`: Secure, HttpOnly, `SameSite=Lax` (or `Strict` with `http.cookieSameSite`), and `Path=/`, with no parent-domain cookie. "Remember this device" uses its own `better-iam.device` cookie with the same attributes. Loopback HTTP development uses non-prefixed names. Deployment and server capabilities (`iam.store`, `bootstrap`, `recoverRoot`, `assertionKey`, `protocolHost`) are never routed. Do not expose them through your own application RPC reflection either. > **Rate limits at the ingress.** Account-level rate limits do not replace ingress controls. Apply request body limits and network rate limits in front of the handler, and in particular to public discovery routes such as `tenants.lookup`. ## Next steps [#next-steps] - [Federation](/docs/federation): What each protocol is for and who configures it. - [Secrets and keys](/docs/operations/deployment/secrets): Where the OAuth and SAML keys come from and how they rotate. - [Protocol jobs](/docs/operations/jobs#protocol-jobs): The background work the OAuth provider, Shared Signals, and SCIM outbound need. # Build and release (/docs/operations/deployment/releases) > How a Better IAM release is checked, packed, smoke-tested as installed tarballs, versioned in lockstep, and prepared for publication. Better IAM ships as a set of workspace packages under `packages/*` that are always released together at one version. A release proves three things before anything leaves the repository: the source passes every check, the packed tarballs install and work in a fresh consumer, and their TypeScript exports resolve. This page is for maintainers cutting a release from the monorepo. ## Release procedure [#release-procedure] ### Set the version [#set-the-version] ```sh node scripts/release-version.mjs 1.4.0 ``` `release-version.mjs` writes the version (`MAJOR.MINOR.PATCH`, optionally with a pre-release suffix) into every package's `package.json`. Then update the changelog and reinstall to refresh the lockfile. ### Run the checks [#run-the-checks] ```sh pnpm check ``` `pnpm check` runs the type checks (the workspace build plus the client, application, Next.js, NestJS, SvelteKit, middleware, and React Router type suites and the console), the test suite, and package validation. Package validation requires every package to be publishable, MIT licensed, public on npm, and at the same version, checks that every export target exists, and runs `publint`. ### Pack [#pack] ```sh pnpm pack:all ``` Every package is packed into `artifacts/`. Nothing is published. ### Smoke-test the packed packages [#smoke-test-the-packed-packages] ```sh node scripts/packed-smoke.mjs # or: pnpm test:packed pnpm check:packed-types ``` The packed smoke test installs all tarballs into a fresh consumer, with the native builds for `argon2` and `better-sqlite3` allowed. It then verifies exports, native dependencies, migrations, and bootstrap: it imports the umbrella package, runs a SQLite migration, bootstraps a root, and runs `better-iam --help`. The packed type check runs `attw` (Are the Types Wrong) on each tarball with the ESM-only profile, so exports resolve under both ESM and bundler resolution. ## PostgreSQL before a release [#postgresql-before-a-release] The default suite skips PostgreSQL-only cases. Run them against an isolated database before tagging: ```sh BETTER_IAM_POSTGRES_URL=postgres://localhost/better_iam_test pnpm test:postgres ``` See [Database operations](/docs/operations/deployment/database#postgresql-integration-checks) for how the suite isolates its cases. ## Publication [#publication] All packages are MIT licensed and publish publicly to npm. Pushing a new version to `main` publishes it: ```sh node scripts/release-version.mjs 1.4.0 # update CHANGELOG.md, pnpm install, pnpm check, commit git push origin main ``` The `publish` job in `.github/workflows/ci.yml` waits for every test job and the PostgreSQL job. When the package version is not on npm yet, it runs `pnpm publish -r --provenance`, so every tarball carries an npm provenance attestation, then tags `v1.4.0` and creates a GitHub release. Pushes that keep the version publish nothing. Pre-release versions (`1.4.0-beta.0`) publish under the `next` dist-tag. pnpm skips packages already published, so rerunning the job finishes a partial release. > **Registry credentials.** The workflow reads an `NPM_TOKEN` repository secret: an npm granular or automation token with publish rights on `better-iam` and the `@better-iam` scope. Never commit registry credentials to the repository. ## Next steps [#next-steps] - [Deployment](/docs/operations/deployment): How an application deploys a released version. - [Changelog](/docs/reference/changelog): What changed in each release. # Secrets and keys (/docs/operations/deployment/secrets) > What the deployment secret protects, how previousSecrets and rotate-secrets rotate it without signing anyone out, and how assertion and protocol keys fit in. Every deployment has one `secret`. It is the root of the encryption that protects stored factors and queued messages, so it has to stay stable for the life of the deployment, and when it must change, it changes in stages rather than all at once. This page explains what the secret covers, how to rotate it, and how the other keys (assertion, OAuth, SAML, session JWT) relate to it. ## What the secret protects [#what-the-secret-protects] **Depends on the secret:** * **Encryption:** authenticator (TOTP) secrets, webhook signing secrets, and undelivered email, SMS, and webhook payloads in the outbox, including queued invitation emails. * **Challenge digests:** password reset, email verification and change, passwordless links and codes, MFA sign-in, phone verification, and passkey ceremonies. * **Assertions:** the key that signs [stateless assertions](/docs/guides/recipes/operations#call-a-downstream-service-with-a-stateless-assertion) is derived from it. **Independent of it:** A rotation leaves these alone: * sessions and API keys, which are hashed without the secret, so nobody is signed out; * invitation links, whose tokens are plain SHA-256 hashes; * OAuth, SAML, SCIM, and Shared Signals, which use their own keys; * signed session tokens (JWTs), which use their own `sts.jwt.signingKeys` and `verificationKeys`. The secret must have at least 32 characters. Generate it from a cryptographic source and store it in your secret manager: ```sh node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))" ``` `doctor` warns with `weak-secret` when the value looks like a placeholder (for example it contains "change me", "replace me", "example", or "placeholder") or has little variety. > **Never replace the secret outright.** Replacing the secret in one step makes every enrolled authenticator and every webhook unusable, and strands queued messages. Always rotate through `previousSecrets`. ## Rotating the deployment secret [#rotating-the-deployment-secret] Rotation runs in four stages. Deploy each stage to every instance, worker, and CLI configuration before the next one begins. ### Introduce [#introduce] Keep the old `secret` and add the new value to `previousSecrets` (at most five). Every process can now open values sealed with either, while still sealing with the old one. Give downstream verifiers `iam.assertionKeys()`, which lists both assertion keys. ### Switch [#switch] Make the new value `secret` and move the old one to `previousSecrets`. New values are sealed with the new secret, and old values and pending links keep working. ### Re-seal [#re-seal] Run `better-iam rotate-secrets` (`iam.rotateSecrets()`). It re-seals stored authenticator secrets, webhook secrets, and pending payloads with the new secret in short transactions and prints what it changed. `--dry-run` only counts. Repeat until it reports `done: true`. ```sh better-iam rotate-secrets --config better-iam.config.mjs --dry-run better-iam rotate-secrets --config better-iam.config.mjs ``` ### Retire [#retire] A day later, when emailed links and assertions issued under the old secret have expired, remove `previousSecrets` everywhere and configure downstream verifiers with `iam.assertionKey()` alone. If the old secret leaked, retire it as soon as `rotate-secrets` reports `done: true`, and never hand its assertion key to new verifiers. `previousSecrets` must list distinct values of at least 32 characters, none equal to `secret`. Configurations created by `better-iam init` read it from `BETTER_IAM_PREVIOUS_SECRETS` (comma-separated), as the console does. ### What `rotate-secrets` reports [#what-rotate-secrets-reports] `iam.rotateSecrets()` also accepts `batchSize` (records per transaction, default 200, 1 to 2000) and `limit` (a per-collection sample, which `doctor` uses). ### Tracking a rotation with `doctor` [#tracking-a-rotation-with-doctor] | Finding | Severity | Meaning | | ----------------------------- | -------- | ------------------------------------------------------------------------------------------------------- | | `secret-rotation-pending` | warning | Stored values still need a previous secret. Run `rotate-secrets`. | | `secret-rotation-unverified` | warning | The sample found nothing left, but did not cover every record. Confirm with `rotate-secrets --dry-run`. | | `previous-secrets-configured` | info | `previousSecrets` is set and no stored value needs it any more. It can go. | | `unreadable-secrets` | error | Stored values open with no configured secret. | `unreadable-secrets` is what happens when the secret was replaced without `previousSecrets`. The fix is to put the old secret back into `previousSecrets` and rotate. `rotate-secrets` exits non-zero with `UNREADABLE_SECRETS` in the same case. ### Keys your application derives from the secret [#keys-your-application-derives-from-the-secret] Keys an application derives from `secret` itself need the same staged treatment. The console, for example, derives its outbound SCIM provisioner's `encryptionKey` from it. Pass the keys derived from `previousSecrets` as the provisioner's `previousEncryptionKeys`, then call `provisioner.rotateKeys()`, which re-seals stored downstream tokens and reports `{ resealed, current, unreadable }`. ## Assertion keys [#assertion-keys] Services that verify [stateless assertions](/docs/guides/recipes/operations#call-a-downstream-service-with-a-stateless-assertion) hold a key derived from the secret, never the secret itself. * `iam.assertionKey()` returns the derived key (64 hexadecimal characters). A service holding only that key verifies with `verifyAssertion` and cannot recover the secret. Treat it as a shared secret between IAM and its services; it is never exposed over HTTP. * `iam.assertionKeys()` returns the current key first, then those of `previousSecrets`. `verifyAssertion`, the NestJS assertion module, and the Next.js edge verifier all accept the list, so hand it out during a rotation and go back to the single key afterwards. ```ts title="Downstream service during a rotation" import { verifyAssertion } from 'better-iam'; // IAM_ASSERTION_KEYS holds JSON.stringify(iam.assertionKeys()) while the secret rotates. const claims = verifyAssertion(token, { key: JSON.parse(process.env.IAM_ASSERTION_KEYS!) as string[], audience: 'reports', }); ``` Assertions cannot be revoked before they expire (at most one hour, five minutes by default), which is why the retire stage waits for them. ## Other keys [#other-keys] These are separate, explicit inputs. Keep each stable, secret, backed up, and separate by purpose; losing an encryption key makes what it sealed unreadable. | Key | Where | Rotation | | ------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | OAuth provider signing JWKs | `createOAuthProvider({ jwks })` | Roll over with a JWKS containing the active private key and the still-valid verification keys. | | OAuth cookie keys | `createOAuthProvider({ cookieKeys })` | Persistent; never regenerated at startup. | | OAuth encryption key (32 bytes) | `createOAuthProvider({ encryptionKey })` | Changing it requires re-encrypting stored protocol artifacts; automatic key migration is not provided. | | SAML SP keys and certificates | `createSamlService` connections or `serviceProvider` | Supplied only through trusted deployment configuration. Roll IdP certificates by listing the previous and new ones in `idpCertificates`. | | Session JWT keys | `sts.jwt.signingKeys`, `verificationKeys` | Move a retired key to `verificationKeys` (public only), where it verifies but never signs. | Keep the OAuth keys identical across replicas and process restarts. No production key is ever generated inside a request handler. See [Protocol mounts](/docs/operations/deployment/protocol-mounts) for where each one is configured. ## Next steps [#next-steps] - [Configuration reference](/docs/operations/deployment/configuration): The `secret`, `previousSecrets`, and `sts.jwt` options with their validation rules. - [Doctor](/docs/operations/storage#doctor): The findings that track a rotation until it is done. - [Database operations](/docs/operations/deployment/database#backups): Back up the keys together with the database.