BetterIAM
Core 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 . 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

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 (SQLite, libSQL, and PostgreSQL) share that schema. Records live in a versioned iam_records table:

ColumnHolds
collectionThe record kind, such as identities or sessions.
idThe record ID. The primary key is (collection, id).
tenant_idThe owning tenant. Tenant ownership of a record is immutable.
unique_keyAn optional natural key, unique per (collection, tenant_id).
dataThe 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:

AreaCollections
Tenancytenants, tenantAliases, tenantDomains, domainOwners, ownerInvitations, memberInvitations
Accounts and sessionsidentities, sessions, externalIdentities, identityLinks
AuthenticationauthChallenges, authMfa, authPasskeys, authDevices, authSignIns, authBlocks, authRateLimits, passwordHistory
Access modelpolicies, policyVersions, roles, bindings, groups, groupMembers, grantAuthorities, principalBoundaries, trusts
Catalogactions, resourceTypes, resources, relationships
Delivery and auditoutbox, webhooks, auditHooks, audit, auditChains (chain heads, keyed by tenant ID)
ProtocolsOAuth, 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

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 for query pushdown, indexes, and diagnostics.

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 for five seconds, so a new block reaches other processes within that time.

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

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.

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

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 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:

MemberPurpose
actions, resourceTypesCatalog entries, validated like the product's own. A name declared twice is rejected at construction.
endpointsHTTP endpoints (POST {basePath}/plugins/{id}/{path}) that run inside a transaction with the authorized principal and may queue deliveries through deliver.
hooks.beforeOperation, hooks.afterOperationRun inside every operation transaction, before the mutation and after it (before the audit record). Throwing aborts the operation.
resolveContextAdds trusted, server-derived keys to the policy evaluation context.
afterAuditRuns for committed events, from the dispatcher, at least once.
migrate, purgePlugin migrations, and removal of plugin-owned records when a tenant is purged.

@better-iam/projects is the reference implementation. See extensions for the adapter and plugin contracts.

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 and protocol mounts.

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page