API reference
Every method of the Better IAM server API: 64 groups and 767 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. |
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, closeOverdueTeamReviews, checkInvariants, archiveAudit, pruneAudit, dispatchAuditHooks, flushAccessUsage | Scheduled jobs and shutdown work. See Scheduled jobs. |
signals | The Shared Signals receiver's server side: the poll job signals.poll() and signals.receive(sourceId, set) for custom transports. See Shared Signals receiver. |
licenses | License management's server side: the jobs licenses.reconcile() and licenses.reclaim(), and licenses.features(identityId, tenantId, { trustedTenantId? }) (platform products unless you trust a tenant) and licenses.products(...) for application code. See License jobs and helpers. |
guests | Guest collaboration's server side: the jobs guests.sweep() and guests.sendReviewReminders(). See Guest collaboration jobs. |
clearances | Security clearances' server side (with the clearances option): the job clearances.sendReminders(). See Security clearance jobs. |
assertionKey, assertionKeys | The keys downstream services verify assertions 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
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.
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' });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, plusPOST {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 withobservability.metrics.bearerToken), andGET {basePath}/.well-known/jwks.json(only withsts.jwt);- the routes of every protocol service mounted with
useProtocol, which are asked first; POST {signals.pushPath}/{sourceId}, the Shared Signals push endpoint, also asked before the API routes.
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, NestJS,
Node middleware) wrap them for you.
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 | 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, closeOverdueTeamReviews, checkInvariants, detectThreats, signals.poll, licenses.reconcile, licenses.reclaim, guests.sweep, guests.sendReviewReminders, clearances.sendReminders, 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 reports the jobs that have stopped running. Most jobs have a matching
CLI command for cron.
Shared Signals receiver
iam.signals is the server side of the Shared Signals receiver: security events (CAEP and RISC SETs) that an
organization's identity providers send about its people. Sources are registered and events read through the
signals group. Push sources need nothing scheduled: providers POST to
{signals.pushPath}/{sourceId} (/api/iam/signals/push/{sourceId} by default), which handler and nodeHandler
serve ahead of the API routes. The signals option sets pushPath, and allowPrivateNetworks and
allowInsecureLocalhost for the address rules of key, discovery, and poll requests.
signals.poll
Fetches events from every active poll source (RFC 8936), or from one with sourceId.
- When: on a schedule, every minute or so, when any organization has a poll source. The interval is how late a polled event can be.
- Permission: none. A deployment operation;
signals.pollis the per-source call for administrators. - Audited as:
signal:receivedfor each new event andsignal:revoke-sessionsfor sessions it ends, by the actorsignal:{sourceId}. - Errors:
INVALID_INPUTfor a malformedsourceId. A failing provider never throws: it is counted inerrorsand recorded on the source aslastError. - Safe to repeat: yes. Events are deduplicated on issuer and
jti, so an event delivered again, for example after overlapping runs, is recognized and acknowledged again.
Each source is asked with returnImmediately: true and up to maxEvents events per request, following
moreAvailable for at most five requests. Every SET is verified and recorded as a push would be. Stored events are
acknowledged with the next request, refused ones are reported in setErrs, and events that cannot be handled yet (keys
unavailable, storage busy) are left for the provider to deliver again. Sources of suspended organizations are skipped.
The result is { sources, received, acknowledged, errors }.
setInterval(() => void iam.signals.poll().catch(reportError), 60_000).unref();signals.receive
Verifies and records one SET for a source, exactly as the push endpoint does, for transports of your own.
- When: in the code that takes SETs from a queue, a bridge, or tests.
- Permission: none. Trusted server code; the SET's signature is what authenticates it.
- Audited as:
signal:received, andsignal:revoke-sessionswhen the source's action ends sessions. - Errors:
SIGNAL_REJECTED(aSignalRejectedErrorwith the RFC 8935err, andtemporary: truewhen a later attempt may pass because the source's keys could not be fetched);NOT_FOUNDfor an unknown or disabled source, or one whose organization is suspended. - Safe to repeat: yes. A SET received before returns its first receipt with
duplicate: true.
The result is { eventId, status, duplicate, identityId? }, where eventId is the received event for
signals.getEvent.
const receipt = await iam.signals.receive(sourceId, compactSet);
if (receipt.status === 'unmatched') console.warn('No person matched', receipt.eventId);License jobs and helpers
iam.licenses is the server side of license management: two scheduler jobs that keep
seats in line with time and activity, and two credential-free readers for application code. Products, pools,
assignments, and seats are managed through the licenses group. Every seat change the jobs make is audited as
license:seat-activate, license:seat-waiting, or license:seat-release by deployment-operator, and birthright
package rules on identity.licenses are re-evaluated for the people whose seats changed.
licenses.reconcile
Brings every active organization's seats in line with assignments, memberships, identity status, and pool terms.
- When: on a schedule, hourly. The interval is how late a pool that starts or ends, or a membership or account that expires, is reflected in seats.
- Permission: none. A deployment operation.
- Audited as: the seat changes, with reason
schedule. - Errors:
NOT_FOUNDfor an unknowntenantId. A tenant that fails does not throw: it is listed infailedTenantsand the others carry on. - Safe to repeat: yes. Reconciliation is idempotent, and each tenant with licenses is reconciled in its own transaction; a second run over unchanged data changes nothing.
Everything else already moves seats as it happens (assignments, pool changes, group membership, disabling, deletion),
so this job catches what changes with time and repairs anything edited by hand. Until it runs nothing is granted past
its time: decisions stop counting a seat held only through a lapsed membership, and an expired account holds nothing.
Suspended tenants are skipped, and a retired product never gets seats back. The result is
{ tenants, activated, waiting, released, failedTenants }, where tenants counts the tenants with licenses.
setInterval(() => void iam.licenses.reconcile().catch(reportError), 60 * 60 * 1000).unref();licenses.reclaim
Removes the direct license assignments of people who have not used their account for the tenant's reclaimAfterDays.
- When: on a schedule, daily.
- Permission: none. A deployment operation; tenants opt in with
licenses.configure. - Audited as:
license:reclaimon the person for each removed assignment (metadata:productId,productKey,assignedAt,lastActivityAt,reclaimAfterDays), then the seat changes with reasonreclaim. - Errors:
INVALID_INPUTfor a malformedtenantId. A tenant that fails, including an unknowntenantId, does not throw: it is listed infailedTenants. - Safe to repeat: yes. Each tenant runs in its own transaction, and a removed assignment is gone, so a second pass finds nothing more.
It visits the tenants with reclaimAfterDays set (or only tenantId). A person is inactive when their last sign-in
(or, without one, their account's creation) and the last use of their own sessions and API keys are older than the
period; "view as" sessions of administrators do not count. The job removes each direct assignment older than the period
whose holder is inactive, and waiting people take the freed seats. Seats held through groups are never reclaimed
(licenses.usage reports them as reclaimableThroughGroups). Each pass also stores the activity it saw on every seat
(lastActivityAt). The result is { tenants, reclaimed, failedTenants }, with the tenantId, productId,
productKey, identityId, and lastActivityAt of every reclaimed assignment.
const { reclaimed } = await iam.licenses.reclaim();
for (const item of reclaimed) console.info('Reclaimed', item.productKey, 'from', item.identityId);licenses.features
Returns the feature keys a person's active seats unlock in a tenant, for server code without a credential.
- When: in your own routes and jobs, wherever a feature depends on the person's license.
- Permission: none. Trusted server code.
- Audited as: not audited.
- Errors:
NOT_FOUNDfor an unknown tenant ortrustedTenantId;INVALID_INPUTfor a malformedidentityIdortrustedTenantId.
The result is the sorted union of the featureKeys of the products the person holds an active seat for, counting
only products defined by the tenant you trust or a tenant above it: the root tenant (the platform's products) unless
you pass { trustedTenantId }. Any tenant can define a product of its own listing any feature key, so the default
keeps a tenant from minting a paid feature for itself; pass an organization's id to also count the products it sells to
its own projects. People who are not active, whose account expired, or who belong to another tenant get an empty list,
and a seat held only through a lapsed membership does not count. Tenant feature flags are separate: combine the two
when a flag rolls a feature out and the license entitles the person.
const features = await iam.licenses.features(identityId, tenantId);
if ((await iam.features.isEnabled(tenantId, 'exports')) && features.includes('exports')) showExport();
// Also count what Acme sells to its own projects:
const acme = await iam.licenses.features(identityId, projectId, { trustedTenantId: acmeId });licenses.products
Returns the keys of the products a person holds an active seat for in a tenant: exactly what policies see as principal.licenses.
- When: in server code that needs the person's products rather than their feature keys.
- Permission: none. Trusted server code.
- Audited as: not audited.
- Errors: as for
licenses.features.
The same people get an empty list as for licenses.features above. Retired products and waiting seats never appear.
Guest collaboration jobs
iam.guests is the server side of guest collaboration: two scheduler jobs that keep
invitations, guest accounts, and sponsors in order. Guests, invitations, and cross-tenant access settings are managed
through the guests group. Identity expiry itself is the retention worker's: purgeDeleted disables a
guest whose access ended, and sweepExpired deletes settled invitations 90 days after they lapsed.
guests.sweep
Marks lapsed guest invitations and ended guest accounts expired, and flags guests whose sponsor can no longer sponsor.
- When: on a schedule, hourly, before
guests.sendReviewRemindersand an outbox run. - Permission: none. A deployment operation.
- Audited as:
guest:expirefor each guest account it closes,guest:sponsor-missingfor each guest it flags, andguest:settings(withpartnersRemoved) when it drops partner entries, all bydeployment-operator. - Errors:
INVALID_INPUTfor a malformedtenantId. - Safe to repeat: yes. Each tenant with pending invitations, open guest accounts, or settings runs in its own transaction, and every change is a state that a second run finds already made.
For every such tenant (or only tenantId) it marks pending invitations past their lapse expired, closes the guest
account of each guest whose access ended (and reopens one an administrator restored with a later end and a
re-enabled identity), removes the accounts of deleted identities, and flags sponsorMissing on active guests whose
sponsor is disabled, expired, deleted, or otherwise unable to sponsor. When the deployment sends email and the tenant
is active, the tenant's active owners get one guest-sponsor-missing email per guest and loss; the flag clears once
the sponsor can sponsor again or guests.setSponsor names a new one. It also
drops partner entries that name purged tenants. The result is
{ invitationsExpired, accountsExpired, accountsRestored, sponsorsMissing, ownersNotified, partnersRemoved }.
guests.sendReviewReminders
Emails each sponsor about the guests whose review is due or whose access ends soon.
- When: on a schedule, hourly, followed by an outbox run.
- Permission: none. A deployment operation.
- Audited as:
guest:review-reminderon each guest reminded, bydeployment-operator, with the sponsor, the earliest date reminded, and the access end. - Errors:
DELIVERY_REQUIREDwhenauthentication.sendEmailis not configured;INVALID_INPUTfor awithinDaysoutside 1 to 90 or a malformedtenantId. - Safe to repeat: yes. A reminder mark per guest and date (
guest-review:{id}:{reviewDueAt},guest-end:{id}:{expiresAt}) prevents repeats, and each tenant runs in its own transaction.
It looks at the active guests of every active tenant (or only tenantId) and reminds the sponsor of each review date
and each access end within withinDays (default 14) once, in one guest-review email per guest that covers whatever
is due, so an unanswered review reminder never stands in for the warning that access is about to end. A renewal moves
both dates, which brings fresh reminders when they come near again. Guests whose sponsor cannot sponsor or has no email
address are skipped (the sweep reports them to the owners). The result is { sent, skipped: { inactive, quiet } },
where each sent entry names the tenant, guest, sponsor, and dueAt.
setInterval(async () => {
await iam.guests.sweep();
await iam.guests.sendReviewReminders();
await iam.auth.dispatchOutbox();
}, 60 * 60 * 1000).unref();Security clearance jobs
iam.clearances is the server side of security clearances (the clearances
option). Schemes, clearances, and labels are managed through the clearances group, and decisions enforce labels by
themselves: suspensions, expiries, interim policy, and NDA acceptance are evaluated on every decision, so no job has to
run for them to take effect.
clearances.sendReminders
Emails security officers about clearances whose reinvestigation is due or whose end comes up.
- When: on a schedule, daily, followed by an outbox run.
- Permission: none. A deployment operation.
- Audited as:
clearance:reminderon each person reminded, bydeployment-operator, with the level id, the earliest date reminded (dueAt), thekinds(reinvestigation,interim-end,expiry), and how many addresses were emailed. - Errors:
FEATURE_DISABLED(403) without theclearancesoption;DELIVERY_REQUIREDwhenauthentication.sendEmailis not configured;INVALID_INPUTfor awithinDaysoutside 1 to 365 or a malformedtenantId. - Safe to repeat: yes. A reminder mark per clearance and date (
clearance-reminder:{id}:{date}) prevents repeats, and each tenant runs in its own transaction.
It looks at the active and interim clearances of every active tenant that holds any (or only those held in
tenantId) and reminds once of each periodic reinvestigation that is due or overdue and each end, interim or final,
within withinDays (default 60). One clearance-reminder email per clearance goes to every active owner of the tenant
that defines the scheme (a project's clearances to the organization's owners) and to the scheme's notify.emails. The
email names the person and the level, never compartments, and links to links.clearances (or links.account). A
changed date is reminded again. People who are not active are skipped. The result is
{ sent, skipped: { inactive, noRecipients } }, where each sent entry names the tenant, the person, dueAt, and how
many addresses were emailed, inactive counts tenants that are not active (an unknown tenantId included), and
noRecipients clearances with something due but nobody to tell.
setInterval(async () => {
await iam.clearances.sendReminders();
await iam.auth.dispatchOutbox();
}, 24 * 60 * 60 * 1000).unref();Groups
Sign-in and people
Authenticate people and machines, manage who exists in a tenant, and how they are organized.
Organizations
The tenant tree, organization addresses and settings, and platform administration.
Access model
What can be done to what, and who holds which permissions.
resource.relations. (3 methods)filtersData filtering: which resources of a type the caller may act on, as a filter for your database query. (2 methods)clearancesSecurity clearances: classification schemes, adjudicated clearances and compartments, and labels every decision enforces. (19 methods)Access lifecycle
Granting access for a purpose and a time, and reporting on it.
Governance
Reviewing, analyzing, and constraining access over time.
Events and integrations
The audit log, outgoing events, and service-to-service trust.
AI agents and models
Agents as accounts, what they may do for people, and governed access to models.
Billing and usage
What people, teams, and organizations spend, the budgets and statements around it, API usage plans, and license seats.
Secrets, keys, and certificates
Cryptographic keys, secrets, tokenized data, and certificates for people, workloads, and servers.
Directory and credentials
The directory served to LDAP applications, and credentials people carry in their wallets.
Privacy
Consent to processing purposes, data-subject requests with statutory deadlines, and legal holds.
Threat detection and response
Attacks on identities found in the audit trail or reported by upstream identity providers, the incidents and risk they raise, and the responses to them.
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.
| Function | What it does |
|---|---|
archiveAudit | Copies every tenant's new audit events, verified and in chain order, to the configured auditArchive sink. |
assertionKey | Returns the key downstream services use to verify the assertions this deployment issues. |
assertionKeys | Returns the assertion key of the current secret followed by those of previousSecrets, for verifiers during a secret rotation. |
authenticate | Resolves a credential to the identity and session behind it, re-checking everything that could have revoked it. |
authorize | Decides whether the caller may perform one action on one resource and returns the decision instead of throwing. |
authorizeMany | Evaluates up to 50 checks for one caller in one tenant in a single transaction, for rendering UI state. |
bootstrap | Creates the platform's root tenant and its first root administrator, once per installation. |
callPlugin | Runs a plugin endpoint from server code inside the same authorized, audited transaction as a built-in method. |
checkInvariants | Evaluates the access invariants of every active organization and records an audit event when one breaks or recovers. |
closeOverdueCertifications | Closes every auto-closing certification campaign whose due date has passed and applies its decisions. |
closeOverdueTeamReviews | Completes every open team membership review whose due date has passed and removes the people it decided to remove. |
detectThreats | Reads each active organization's new audit events, verifies their hash chain, and raises threat detections, incidents, and responses. |
dispatchAuditHooks | Runs plugin afterAudit hooks, events.onEvent, and in-process subscribers for audit events that have committed. |
flushAccessUsage | Writes the access usage buffered in memory to storage now. |
initialize | Creates or upgrades the database schema and runs one-time data upgrades so the instance can serve requests. |
listAccessible | Lists the registered resources of one managed type that the caller may perform an action on, one page at a time. |
planResources | Returns which resources of one type the caller may perform an action on, as a filter for your own database query. |
pruneAudit | Deletes one tenant's audit events older than a retention period while keeping the rest of the chain verifiable. |
purgeDeleted | Runs the retention worker: ends access whose time is up and removes deleted tenants past their retention window. |
reconcilePackages | Applies access-package rules: people who match a rule receive the package, and automatic holders who stopped matching lose it. |
recoverRoot | Creates an additional root administrator in the root tenant when nobody can sign in as root any more. |
require | Checks one action like authorize and throws ACCESS_DENIED (403) when it is not allowed. |
rotateSecrets | Re-seals values encrypted with a previous deployment secret using the current one, so the old secret can be retired. |
selfCheck | Reports configuration and storage problems an operator should act on, each with a severity and a fix. |
sendAccessDigest | Emails each organization's owners its access report when the report has something in it, at most once per interval. |
sendExpiryReminders | Emails each person whose access ends soon one reminder listing it, once per item and end date. |
sweepExpired | Deletes records nothing reads any more (expired sessions, protocol artifacts, old deliveries) so storage stops growing with traffic. |
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
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. - Permission: none. A deployment operation.
- Audited as: not audited. Progress is kept in one archive cursor per tenant.
- Errors:
NO_AUDIT_ARCHIVE(501) when noauditArchiveis configured;INVALID_INPUTforlimitoutside 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).
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.
const result = await iam.archiveAudit();
if (result.failed.length) alerts.error('Audit archive is failing', result.failed);iam.archiveAudit(
input?: { tenantId?: string; limit?: number } | undefined,
): Promise<AuditArchiveResult>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 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.
// 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' });iam.assertionKey(): stringassertionKeys
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). If the old secret leaked, never give its key
to new verifiers.
// 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',
});iam.assertionKeys(): string[]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:mismatchwhen a session bound to its sign-in network is presented from another address. - Errors:
UNAUTHENTICATEDfor a missing, malformed, expired, idle, or revoked credential, or a disabled or expired identity;MFA_REQUIREDwhen the session still owes MFA;EMAIL_UNVERIFIEDwhen email verification is required and missing;TENANT_UNAVAILABLEorTENANT_INACTIVEwhen the organization is not active;IP_NOT_ALLOWEDorIP_BLOCKEDwhen 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.
const { identity, session } = await iam.authenticate({ headers: request.headers });
return Response.json({ name: identity.name, tenantId: session.tenantId, mfa: session.mfa });iam.authenticate(
input: CredentialInput,
): Promise<AuthenticatedPrincipal>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}/authorizeandclient.authorize(input). - Permission: none to call. The credential in the request (
tokenorheaders) is the principal being checked. - Audited as: the requested action, only for denials (outcome
deny) and root overrides. Allowed checks are not recorded. - Errors:
UNAUTHENTICATEDwhen the credential is missing, expired, idle, or revoked;MFA_REQUIREDwhen the session still owes MFA;TENANT_UNAVAILABLEorTENANT_INACTIVEwhen the caller's organization or one of its ancestors is not active;NOT_FOUNDfor an unknown tenant or a managed resource that is not registered;RESOURCE_RESOLVER_REQUIREDfor an application resource type without theresolveResourceoption;RESOURCE_MISMATCHwhenresolveResourcereturns 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,
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
tenantIdis denied. - An impersonation session is allowed only what both the member and the administrator behind it may do.
- With the
accessUsageoption on, each allowed check counts as usage for role mining. - With the
clearancesoption on, a resource carrying a classification label is refused first, before any role or policy is read, unless every party of the session (the person, an agent key's sponsor, each agent of a delegated session, the administrator behind "view as") holds a clearance that dominates it. This applies to root administrators too, unlessclearances.appliesToRootis false. The refusal is reported asACCESS_DENIEDlike any other and audited with the metadata{ mandatory: 'clearance' };iam:*actions are never subject to labels.
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 });iam.authorize(
request: AuthorizationRequest,
): Promise<Decision>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}/authorizeManyandclient.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_INPUTfor no checks, more than 50, or a check withoutaction,resource.type, orresource.id; otherwise the errors ofauthorize.
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.
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);iam.authorizeMany(
request: BatchAuthorizationRequest,
): Promise<{ results: BatchResult[] }>bootstrap
Creates the platform's root tenant and its first root administrator, once per installation.
- When: once, right after the first
initialize. - CLI:
bootstrap, which reads theBETTER_IAM_ROOT_*environment variables. - Permission: none. A deployment operation, never exposed over HTTP.
- Audited as:
root:bootstrap, bydeployment-operator. - Errors:
ALREADY_INITIALIZED(409) when a root tenant exists;WEAK_PASSWORDfor a password shorter than 12 characters or refused by the password policy,BREACHED_PASSWORDwhen a configured breach check matches it;SLUG_TAKENwhenslugis in use;INVALID_INPUTfor a malformed email or an empty name. - Safe to repeat: yes. Every call after the first fails with
ALREADY_INITIALIZEDand 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.
const { tenant, identity } = await iam.bootstrap({
email: 'platform-admin@example.com',
name: 'Platform administrator',
password: process.env.BETTER_IAM_ROOT_PASSWORD!,
slug: 'platform',
});iam.bootstrap(
input: {
email: string;
name: string;
password: string;
rootName?: string;
slug?: string;
},
): Promise<{ tenant: Tenant; identity: PublicIdentity; mfaEnrollmentRequired: true }>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 withclient.$request('plugins/{pluginId}/{path}', input). - Permission: the endpoint's declared
action, oniam/{tenantId}. - Audited as: the endpoint's
action. - Errors:
NOT_FOUNDwhen no plugin with that id has aPOSTendpoint atpath;INVALID_INPUTwheninput.tenantIddiffers fromtenantIdor the endpoint'svalidaterejects the input;ACCESS_DENIEDwithout the action; plus whatever the endpoint's handler throws.
Plugins 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.
const project = await iam.callPlugin(
{ headers: request.headers },
{ pluginId: 'projects', path: 'create', tenantId, input: { name: 'Website relaunch' } },
);iam.callPlugin(
credential: CredentialInput,
input: { pluginId: string; path: string; tenantId: string; input?: unknown },
): Promise<unknown>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. - Permission: none. A deployment operation.
- Audited as:
invariant:broken(outcomedeny) when an invariant starts failing or gains violators, andinvariant:restoredwhen it passes again, bydeployment-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 (CLI check-invariants). See
change safety.
iam.checkInvariants(
input?: { tenantId?: string } | undefined,
): Promise<InvariantCheckResult>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. - Permission: none to call. Revocations run under the campaign creator's grant authority.
- Audited as:
certification:auto-closefor each campaign, with the outcome counts, plusiam:bindings:deletefor each removed binding, bydeployment-operator. - Errors:
NOT_FOUNDfor an unknowntenantId. - 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 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.
iam.closeOverdueCertifications(
input?: { tenantId?: string } | undefined,
): Promise<CertificationAutoCloseResult>closeOverdueTeamReviews
Completes every open team membership review whose due date has passed and removes the people it decided to remove.
- When: on a schedule, hourly or daily with the other jobs.
- Permission: none. A deployment operation.
- Audited as:
team:member:remove(withsource: reviewand thereviewId) for each person removed andteam:review:completewith the counts, bydeployment-operator. - Safe to repeat: yes. Each review is re-read and completed in its own transaction, so a review someone completed or cancelled meanwhile is left alone and none is completed twice.
It looks at the open reviews past their dueAt (of every organization, or only tenantId) and skips those of
organizations that are not active or of teams that no longer exist. Completing a review works as
teams.completeReview does: people decided remove leave the team,
people nobody decided on follow the review's onUndecided, and people who already left or whom team sync now manages
are counted as gone. The birthright packages of the people removed are re-evaluated afterwards. A review that fails
does not stop the others. The result is { completed, removed, failed }, where failed lists the reviewId and
message of each review that could not be completed.
const { completed, failed } = await iam.closeOverdueTeamReviews();
if (failed.length) await notifyAdmins(failed);iam.closeOverdueTeamReviews(
input?: { tenantId?: string } | undefined,
): Promise<{
completed: number;
removed: number;
failed: Array<{ reviewId: string; message: string }>;
}>detectThreats
Reads each active organization's new audit events, verifies their hash chain, and raises threat detections, incidents, and responses.
- When: on a schedule, every minute. The interval is how late a detection can be.
- CLI:
detect-threats. - Permission: none. A deployment operation.
- Audited as:
threat:detectionandthreat:incident-openfor what it finds,threat:risk-changewhen an identity's risk level moves, and the playbook responses it takes (threat:contain,threat:revoke-sessions,threat:block-network,threat:notify,threat:response-braked, and so on), all by the actorthreat-detection. - Errors:
NOT_FOUNDfor an unknowntenantId;INVALID_INPUTfor amaxEventsoutside 1 to 20,000. - Safe to repeat: yes. Each tenant has a cursor, the sequence and hash of the last event read, and every detection a unique key, so re-reading events or overlapping runs never records anything twice.
For every active organization (or only tenantId) it reads at most maxEvents unread events (2000 by default) from
the tenant's cursor; a tenant's first run starts one day back. It checks that each event follows the one before and
that its hash recomputes, and reports a break as an audit-tampering detection without stopping. Then it evaluates
the tenant's detection rules over the new events and the history their windows reach back to, records detections
grouped into incidents, raises identities' risk (which policies read as principal.riskLevel and
principal.riskScore), and runs the tenant's playbooks for new detections, with at most maxAutomaticContainments
automatic containments per tenant and run. The module's own events are verified but never judged. A tenant whose
evaluation fails records nothing and keeps its cursor, and the others carry on. The result is
{ tenants, eventsScanned, detections, incidentsOpened, responses, braked, chainBreaks, pending }, where pending
counts tenants with more unread events than one run reads. Rules, incidents, and playbooks are managed through the
threats group, whose detect runs one tenant on demand.
setInterval(async () => {
const run = await iam.detectThreats();
if (run.chainBreaks) await alertSecurityTeam('Audit chain failed verification', run);
}, 60_000).unref();iam.detectThreats(
input?: { tenantId?: string; maxEvents?: number } | undefined,
): Promise<ThreatDetectionRun>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.
iam.events.subscribe('iam:identities:*', async (event) => {
await searchIndex.refreshIdentity(event.resourceId);
});
setInterval(() => void iam.dispatchAuditHooks().catch(reportError), 60_000);iam.dispatchAuditHooks(): Promise<{ dispatched: number }>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 everyflushIntervalMs(one minute by default) and whenmaxBufferedpairs 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.
process.on('SIGTERM', async () => {
await iam.flushAccessUsage();
process.exit(0);
});iam.flushAccessUsage(): Promise<{ written: number }>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. - Permission: none. A deployment operation, never exposed over HTTP.
- Errors:
SCHEMA_VERSIONwhen 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.
// In a release step, before the new version starts serving.
await iam.initialize();iam.initialize(): Promise<void>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}/listAccessibleandclient.listAccessible(input). - Permission: none to call. Results are filtered for the request's credential.
- Audited as: not audited.
- Errors:
INVALID_ACTIONfor an action the catalog does not know;INVALID_RESOURCE_TYPEfor an unknown type or one your application resolves itself;INVALID_INPUTforlimitoutside 1 to 1000 oroffsetoutside 0 to 1,000,000;NOT_FOUNDfor an unknown tenant; plus the credential errors ofauthorize.
It is the reverse of authorize: instead of asking about one resource, it evaluates every
registered resource 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.
With the clearances option, the type's classification labels are read once and every resource is checked against
them as authorize would, so resources labeled above the caller's clearance are left out, root administrators'
included unless clearances.appliesToRoot is false.
const { resources, total } = await iam.listAccessible({
headers: request.headers,
tenantId,
action: 'workspaces:read',
type: 'workspace',
limit: 25,
offset: 0,
});iam.listAccessible(
request: AccessibleResourcesRequest,
): Promise<{ resources: ResourceRecord[]; total: number }>planResources
Returns which resources of one type the caller may perform an action on, as a filter for your own database query.
- When: in the request path, for list pages over application data (the rows live in your database, not in IAM).
Also served as
filters.planover HTTP. - Permission: none to call. The plan is the request credential's.
- Audited as: not audited.
- Errors:
INVALID_INPUTfor aniam:*action or an internal type;UNSUPPORTED_FILTERwhen the plan cannot be exact, including the security clearance cases below; plus the credential errors ofauthorize.
Where listAccessible evaluates the resources IAM stores, a plan works for any type: the server partially evaluates
the caller's grants, denies, boundaries, relationships and session limits and returns always, never, or
conditional with a filter over id and the resource's attributes, with the same result authorize would give for
each row. Compile it with filterToSql, filterToPrisma, filterToMongo or filterMatches from @better-iam/core.
With the clearances option, every plan (a root administrator's too) is AND-ed with a filter over id that leaves
out the resources labeled above the caller's clearance, which compiles to every target. A plan answers
UNSUPPORTED_FILTER for an application type the scheme requires labels on, for any application type while a label in
the tenant passes down to children, and when a statement conditions on a label's resource keys; labels your
resolveResource asserts are not visible to plans, so check such rows with authorize
(security clearances).
import { filterToSql } from 'better-iam/core';
const plan = await iam.planResources({ headers: request.headers, tenantId, action: 'documents:read', type: 'document' });
if (plan.kind === 'never') return [];
const where = filterToSql(plan.filter, { dialect: 'postgres', column: (field) => columns[field] });iam.planResources(
request: PlanResourcesRequest,
): Promise<ResourcePlanResult>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. - Permission: none. A deployment operation, with no tenant check.
- Audited as:
audit:pruneon the tenant, bydeployment-operator, with the number deleted, the cutoff, and the sequence and hash the chain now starts after. - Errors:
INVALID_INPUTfor a missingtenantIdor aretentionMsoutside 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 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.
const { deleted, heldForArchive } = await iam.pruneAudit({
tenantId,
retentionMs: 365 * 24 * 60 * 60 * 1000,
});iam.pruneAudit(
input: { tenantId: string; retentionMs: number },
): Promise<{
deleted: number;
prunedThroughSequence?: number;
prunedThroughHash?: string;
heldForArchive?: true;
}>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. - Permission: none. A deployment operation.
- Audited as:
identity:expirefor each identity it disables andtenants:purgefor each purged tenant tree, bydeployment-operator. Audit records of purged tenants are kept. - Errors:
INVALID_INPUTwhenretentionMsis 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
retentionMsago (default 30 days), their descendants, plugin-owned records through pluginpurgecallbacks, 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.
const result = await iam.purgeDeleted({ retentionMs: 30 * 24 * 60 * 60 * 1000 });iam.purgeDeleted(
input?: { retentionMs?: number } | undefined,
): Promise<PurgeResult>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. - 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, andpackage:auto-revokefor each change, pluspackage:auto-suspended,package:auto-braked,package:auto-failed,package:auto-resumed, andpackage:auto-confirmfor rule problems and confirmations. - Errors:
INVALID_INPUTfor apackageIdwithouttenantId,confirmwithoutpackageId, or alimitoutside 1 to 10,000;NOT_FOUNDfor an unknowntenantId. - 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
staleand retried on the next run.
This is how 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.
const result = await iam.reconcilePackages();
if (result.failed.length || result.suspended.length || result.braked.length) await notifyAdmins(result);iam.reconcilePackages(
input?: | { tenantId?: string; packageId?: string; limit?: number; confirm?: boolean }
| undefined,
): Promise<PackageReconcileResult>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, which reads theBETTER_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, bydeployment-operator. - Errors:
NOT_INITIALIZEDbeforebootstraphas run;IDENTITY_EXISTSwhen the email already belongs to an identity in the root tenant;WEAK_PASSWORDorBREACHED_PASSWORDfor a password the policy refuses. - Safe to repeat: each call adds another administrator and another
root:recoverevent.
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 and repair
or remove the lost account. Treat it as a break-glass procedure and alert on root:recover events.
iam.recoverRoot(
input: { email: string; name: string; password: string },
): Promise<{ identity: PublicIdentity; tenantId: string; mfaEnrollmentRequired: true }>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_DENIEDwhen the decision is a denial; otherwise the errors ofauthorize.
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.
await iam.require({
headers: request.headers,
tenantId,
action: 'invoices:approve',
resource: { type: 'invoice', id: invoiceId },
});iam.require(
request: AuthorizationRequest,
): Promise<void>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
secretand the old value inpreviousSecrets. - CLI:
rotate-secrets. - Permission: none. A deployment operation.
- Audited as: not audited.
- Errors:
INVALID_INPUTfor abatchSizeoutside 1 to 2000 or alimitthat 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.
const preview = await iam.rotateSecrets({ dryRun: true });
if (!preview.done) await iam.rotateSecrets();iam.rotateSecrets(
options?: SecretRotationOptions | undefined,
): Promise<SecretRotationResult>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. - Permission: none. A deployment operation.
- Errors:
INVALID_INPUTforcapoutside 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 withsynchronousat NORMAL or OFF), andunreadable-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), andaudit-archive-behind(unarchived events older than a day, withauditArchive). - Info:
storage-undescribedandprevious-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.
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'));iam.selfCheck(
options?: SelfCheckOptions | undefined,
): Promise<SelfCheckResult>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. - Permission: none. A deployment operation that includes every report section.
- Audited as:
tenant:access-digeston each organization emailed, bydeployment-operator, with the recipient and finding counts. - Errors:
DELIVERY_REQUIREDwhenauthentication.sendEmailis not configured;NOT_FOUNDfor an unknowntenantId;INVALID_INPUTfor 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: 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).
await iam.sendAccessDigest({ withinMs: 14 * 24 * 60 * 60 * 1000 });
await iam.auth.dispatchOutbox();iam.sendAccessDigest(
input?: | {
tenantId?: string;
withinMs?: number;
unusedForMs?: number;
minimumIntervalMs?: number;
}
| undefined,
): Promise<AccessDigestResult>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. - Permission: none. A deployment operation.
- Audited as:
identity:expiry-reminderon each person emailed, bydeployment-operator, with the reminded items. - Errors:
DELIVERY_REQUIREDwhenauthentication.sendEmailis not configured;NOT_FOUNDfor an unknowntenantId;INVALID_INPUTfor awithinMsoutside 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.
const { sent } = await iam.sendExpiryReminders({ withinMs: 3 * 24 * 60 * 60 * 1000 });iam.sendExpiryReminders(
input?: { tenantId?: string; withinMs?: number } | undefined,
): Promise<ExpiryReminderResult>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. - Permission: none. A deployment operation.
- Audited as: not audited.
- Errors:
INVALID_INPUTfor 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 so its backlog check agrees.
const { total, truncated } = await iam.sweepExpired({ deliveryRetentionMs: 14 * 24 * 60 * 60 * 1000 });iam.sweepExpired(
options?: SweepOptions | undefined,
): Promise<SweepResult>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.
import { createScimService } from 'better-iam/scim';
const scim = createScimService({ ...iam.protocolHost });
iam.useProtocol(scim);iam.useProtocol(
protocol: ProtocolService,
): voidBetter IAM is created by Sean Filimon
Last updated