Databases
Choose the PostgreSQL, SQLite, or libSQL adapter for Better IAM, connect it, run migrations, and learn what every adapter guarantees.
Better IAM keeps all of its state in your own database: tenants, identities, sessions, roles, policies, the audit
chain, and the delivery outbox. It talks to that database only through one small contract, IamStore, implemented
by a that you pass as the database option of betterIam(). Three
adapters ship with Better IAM, and you can write your own for any other database.
| Adapter | Package | Factory | Driver | Use it for |
|---|---|---|---|---|
| PostgreSQL | @better-iam/adapter-postgres | postgresAdapter | pg with Kysely | Production, several application instances, sustained concurrent writes, large datasets. |
| SQLite | @better-iam/adapter-sqlite | sqliteAdapter | better-sqlite3 (native) with Kysely | Development, tests, and single-host deployments with a persistent local disk. |
| libSQL | @better-iam/adapter-libsql | libsqlAdapter | @libsql/client | Remote Turso or sqld databases, embedded replicas, and encrypted local files. |
All three adapters are built on the same RecordStore base from better-iam/core and pass the same conformance
suite, so they behave identically: the same strict filters, the same record ordering, the same serialized
transactions. You can start on SQLite and move to PostgreSQL later with one command
(store-copy).
Setup
Install the adapter
The umbrella package installs all three adapters and exposes them as subpaths (better-iam/adapter-postgres,
better-iam/adapter-sqlite, better-iam/adapter-libsql):
npm i better-iamTo keep dependencies small, install the server and only the adapter you use instead. Import from
@better-iam/adapter-postgres, @better-iam/adapter-sqlite, or @better-iam/adapter-libsql in that case.
npm i @better-iam/server @better-iam/adapter-postgres@better-iam/cli depends on the SQLite and PostgreSQL adapters, so installing the CLI also installs the native
better-sqlite3 driver. Allow its install script in your package manager (for pnpm, onlyBuiltDependencies).
Pass the adapter to betterIam()
Create the adapter once per process and pass it as database:
import { betterIam } from 'better-iam';
import { postgresAdapter } from 'better-iam/adapter-postgres';
export const iam = betterIam({
database: postgresAdapter({ connectionString: process.env.DATABASE_URL! }),
secret: process.env.BETTER_IAM_SECRET!,
baseURL: process.env.BETTER_IAM_BASE_URL!,
});database is required: betterIam() throws INVALID_CONFIG without it. Adapter factories validate their own
options when called and also throw INVALID_CONFIG, so a bad connection string fails at startup rather than on the
first request.
Create the schema
The schema is never created implicitly by request handlers. Run the migration once per deploy, before the new release serves traffic:
npx better-iam migrate --config better-iam.config.mjsThe same step is available in code as await iam.initialize(). See Migrations for
what runs, the iam_migrations ledger, and CI usage.
Check the connection
npx better-iam doctor --config better-iam.config.mjsdoctor prints the adapter, schema version, applied migrations, record counts per collection, and adapter
settings under storage, followed by findings such as schema-behind or sqlite-durability. See
Storage operations.
Usage
Configure from environment variables only
Containers and CI jobs without a configuration file can describe the database with BETTER_IAM_DATABASE_URL and
BETTER_IAM_SECRET. The CLI uses them when it finds no better-iam.config.mjs (or .js, .ts) in the working
directory or its parents and no --config or BETTER_IAM_CONFIG. In code, configFromEnv() from
better-iam/cli builds the same options:
import { betterIam } from 'better-iam';
import { configFromEnv } from 'better-iam/cli';
export const iam = betterIam({
...(await configFromEnv()),
authentication: {
sendEmail: async (message) => {
/* deliver through your mail provider */
},
},
});BETTER_IAM_DATABASE_URL value | Adapter |
|---|---|
postgres://… or postgresql://… | postgresAdapter({ connectionString }) |
sqlite:./iam.db | sqliteAdapter({ filename: './iam.db' }); sqlite: alone opens :memory: |
A path ending in .db, .sqlite, or .sqlite3, or :memory: | sqliteAdapter({ filename }) |
libsql://…, https://…, wss://…, or file:… | libsqlAdapter({ url }), authenticated with BETTER_IAM_DATABASE_TOKEN. Needs @better-iam/adapter-libsql installed (MISSING_DEPENDENCY otherwise). |
Anything else fails with INVALID_CONFIG. configFromEnv() also reads BETTER_IAM_PREVIOUS_SECRETS,
BETTER_IAM_BASE_URL (default http://localhost:3000), BETTER_IAM_BASE_PATH, and BETTER_IAM_TRUSTED_ORIGINS.
Every other option keeps its default, so plugins are not loaded and their migrations do not run in this mode.
Write a starter configuration
better-iam init writes a better-iam.config.mjs (or .ts with --typescript) for the database you choose. It
never overwrites an existing file.
npx better-iam init --database postgres --typescript--database | Adapter line the file contains |
|---|---|
sqlite (default) | sqliteAdapter({ filename: env.BETTER_IAM_DATABASE ?? './better-iam.db' }) |
postgres | postgresAdapter({ connectionString: env.DATABASE_URL }) |
libsql | libsqlAdapter({ url: env.BETTER_IAM_DATABASE_URL ?? 'file:./better-iam.db', authToken: env.BETTER_IAM_DATABASE_TOKEN }) |
Close the adapter on shutdown
The instance exposes its adapter as iam.store. Close it when the process stops so connections and file handles
are released. Closing inside a transaction fails with TRANSACTION_ACTIVE, and any call after closing fails with
STORE_CLOSED.
import { iam } from './iam';
process.on('SIGTERM', async () => {
await iam.store.close();
process.exit(0);
});Share a database with your application
Better IAM creates three tables (iam_records, iam_schema_version, iam_migrations) and indexes prefixed
iam_records_. They can live in the same database as your product's own tables. Your product data stays in your
tables: Better IAM never writes to them, and a write to your tables is not atomic with an IAM transaction. See
Schema for every table and collection.
Change IAM data only through Better IAM
Records reference each other without foreign keys; the services enforce those references inside serialized
transactions. Writing to iam_records with raw SQL can leave a session alive after its identity was disabled or
break the audit chain. Use the API, the CLI, or the adapter.
What every adapter guarantees
The storage rules come from the IamStore contract and are tested by the conformance suite in
@better-iam/core/conformance; the last row describes how the server uses the store:
| Guarantee | What it means |
|---|---|
| Serialized transactions | IAM write transactions run one at a time per database, and take the lock before reading. Token consumption, last-owner protection, and plan limits depend on this. |
| Writes need a transaction | insert, put, and delete outside transaction() fail with TRANSACTION_REQUIRED. Nested transactions join the outer one and roll back with it. |
| Strict filters | Typed equality on top-level fields: the string "1" never matches the number 1, and undefined matches only an absent field. |
| Code-point ordering | Results are ordered by id in code-point order, identical to SQLite BINARY and PostgreSQL "C" collation. |
| Natural keys | uniqueKey is unique within its collection and tenant, enforced by a database constraint. A duplicate fails with CONFLICT. |
| Error hygiene | Driver errors become IamError codes (CONFLICT, STORAGE_BUSY, STORAGE_ERROR) without SQL text or record data. |
| No permission cache | Every decision reads current state, so a revocation applies on the next request in every process. |
What is not supported
- Other databases. There are no MySQL, SQL Server, MongoDB, DynamoDB, or Cloudflare D1 adapters, and no Prisma or
Drizzle adapters. Better IAM does not generate ORM models or migrations for your ORM: its schema
is three fixed tables that its own migrations manage. To use another database, write an adapter against the
IamStorecontract and run the conformance suite; see Write a database adapter. - Edge runtimes. The adapters need Node.js 22.12 or newer and cannot run in edge functions. At the edge, use
the database-free entry points such as
better-iam/session-tokensandbetter-iam/next/edge. - Raw SQL access. Reading
iam_recordsfor reporting is possible, but every write must go through Better IAM.
Next steps
Pool sizing, the serializing advisory lock, poolers, and serverless hosts.
SQLiteWAL and durability settings, several processes on one file, and in-memory test databases.
libSQL and TursoRemote databases, embedded replicas, and encrypted local files.
MigrationsThe schema steps, the iam_migrations ledger, upgrades, and CI.
SchemaEvery table, index, indexed field, and collection.
Storage operationsSnapshots, moving between databases, and doctor.
Better IAM is created by Sean Filimon
Last updated
Testing policies
Check policy documents before and after they ship with the offline evaluator, policies.test, policy lint, policies.simulate, impact previews, invariants in CI, the policy playground, and vitest.
PostgreSQL
Run Better IAM on PostgreSQL with postgresAdapter, including its options, the serializing advisory lock, pool sizing, poolers, serverless hosts, and testing.