BetterIAM
Core concepts

Architecture overview

How Better IAM is put together, from the packages and the surfaces of betterIam() to the pipeline every operation runs through.

@better-iam/core@better-iam/auth@better-iam/server@better-iam/clientarchitecture.mdindex.tsoperations.tsprincipals.tshttp.ts

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

These words appear on almost every page. Throughout the documentation, a dotted underline marks a term; select it for a short definition. The glossary lists every term.

TermMeaning
TenantAn 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.
IdentityA person (kind: 'user') or a machine (kind: 'service') in one tenant.
SessionThe stored record behind a credential: a signed-in user session, an API key, or an assumed-role session.
PrincipalThe caller an authorization decision is about: an identity together with the session it presented. Policies read its properties as principal.*, such as principal.mfa.
ActionA named operation, such as documents:read or the built-in iam:identities:create.
ResourceThe thing an action is performed on, named type/id, such as document/42.
PolicyA versioned JSON document of allow and deny statements over actions and resources, with optional conditions.
RoleA named set of permissions or policies that can be handed to people.
BindingThe grant of a role to an identity or a group in a tenant. Bindings can be temporary, or eligible for just-in-time activation.
BoundaryA policy that caps what a tenant or an identity can ever be granted. Boundaries constrain; they never grant access.
Grant authorityThe 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

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 implement IamStore for SQLite, libSQL, and PostgreSQL.
  • The authentication and protocol packages (OAuth/, , ) 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

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:

MemberWhat it is for
apiEvery operation, grouped: api.auth for end-user authentication, plus the provisioning groups.
authorize, authorizeMany, listAccessibleAuthorization queries: one decision, up to 50 decisions for one tenant, and a reverse query over managed resources.
requireauthorize that throws ACCESS_DENIED; call it right before a protected operation.
handler, nodeHandlerThe Fetch (Request/Response) and Node.js HTTP transports for the whole API.
eventsIn-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.
initializeRuns 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:

GroupWhat it is for
tenantsCreate organizations, rename, move, suspend, and delete them, and set their authentication policy and plan limits.
identitiesCreate, invite, update, offboard, and delete people; export their data; end their sessions.
serviceAccounts, credentialsMachine identities and the API keys they authenticate with.
groupsSets of identities that receive roles together.
roles, policies, bindings, authoritiesThe access model: what roles allow, who holds them, and who may hand them out.
actions, resourceTypes, resources, relationshipsThe resource catalog: the vocabulary of actions and resource types, managed resources, and sharing.
trustPlatform-controlled permission for an identity to assume a role in another tenant.
linksExplicit links between one person's identities in different tenants, for account switching.
rootGrant and list platform root administrators.
accessRequestsTime-boxed requests for access that a reviewer approves.
webhooks, auditSigned event deliveries and the tamper-evident audit log.
assertionsShort-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 lists every group and method with its HTTP route and TypeScript signature.

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.

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' });

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

Some members exist for trusted server code and deployment tooling only:

MemberWhat it is for
iam.authLow-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.storeThe raw storage adapter, for migrations, snapshots, and diagnostics.
iam.bootstrap, iam.recoverRootCreate the root tenant and first root administrator, or a replacement administrator when everyone is locked out.
iam.assertionKeyThe derived key your other services use to verify assertions.
iam.protocolHostThe trusted callbacks that the OAuth, SAML, and SCIM packages use to sign people in and provision them.
JobsScheduled maintenance such as purgeDeleted (retention), sweepExpired (expired sessions and artifacts), and rotateSecrets (re-seal data after a secret rotation). See scheduled 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

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:

ModuleResponsibility
options.tsOption types and resolveConfig, which validates and defaults configuration once.
catalog.tsThe permission catalog: built-in and declared actions, resource types, tenant-defined registrations, policy validation.
plugins.tsPlugin validation at construction.
context.tsThe shared ServerContext: configuration, storage, auth, catalog, and record helpers (tenants, ancestry, authorities, scoping, owner setup).
events.tsChained audit recording, in-transaction fan-out, webhook signing and transport, post-commit dispatch, subscriptions.
decisions.tsGrant paths, relationships, resource resolution, prepareDecision, decide, and simulated principals for reviews.
observe.tsObservability spans around operations, authorization queries, authentication calls, and HTTP requests.
assertions.tsStateless HS256 assertions: key derivation, issuance (the assertions group), and verifyAssertion.
sync.tsConfiguration as code: the config group (export, plan, apply) over the mutation helpers the api/* files export.
usage.tsAccess usage tracking (the accessUsage option): an in-memory recorder behind ctx.usage, flushed in batches.
invariants.tsAccess invariants: evaluation, the snapshot and verify pair operation runs around access-changing actions, and the checkInvariants job.
agreements.tsTerms of use: acceptance currency and the principal.agreements and principal.pendingAgreements context.
principals.tsCredential resolution for user sessions, API keys, and assumed roles; per-transaction re-validation.
operations.tsThe transactional operation envelope, authorize, authorizeMany, listAccessible, plugin calls.
flows.tsMulti-step flows: invitation redemption, account linking and switching, role assumption.
api/*.tsOne file per API group; api/index.ts composes them.
lifecycle.tsinitialize (migrations and audit-chain backfill), bootstrap, recoverRoot, and the retention worker purgeDeleted.
retention.tssweepExpired: deletes expired sessions, protocol artifacts, and old deliveries by walking the expiry indexes.
self-check.tsselfCheck: the configuration and storage findings behind doctor (schema, durability, secrets, transports, scheduled jobs).
secrets.tsrotateSecrets: re-seals values encrypted with previousSecrets using the current deployment secret.
federation.tsprotocolHost callbacks for the OAuth, SAML, and SCIM packages.
http.tsFetch 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

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

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.

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

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). 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

Plugin beforeOperation hooks run, then the change itself. Afterwards the transaction checks separation-of-duties rules (one person may not hold two conflicting roles) and access invariants (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

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.

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.

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

Was this page helpful?

Last updated on

On this page