Production checklist
A grouped checklist of everything to configure, schedule, and monitor before a Better IAM deployment serves real users, with a link to the page that explains each item.
Better IAM runs inside your process and keeps its state in your database, so production readiness is a set of decisions you make once and checks you keep running. This page lists them as a checklist, grouped by area. Each item is one thing to do or confirm, with a link to the page that explains it. When you finish, you will have:
- a stable deployment secret with a rotation plan, and protocol keys supplied from your secret store;
- a durable, migrated database with a bootstrapped root that uses MFA;
- HTTPS, exact trusted origins, and host-only session cookies;
- rate limits at both the account level and your ingress;
- every scheduled job running, with
doctorwatching for the ones that stop; - backups of the database and its keys, and an audit archive outside the database;
- health checks, metrics, and alerts;
- a deploy procedure that migrates first and stops on a failing
doctor --strict.
The Operations overview walks through the same ground as a sequence of steps. Use this page to track what is done.
Prerequisites
- A Better IAM instance that already works in development. If you do not have one, start with the Quickstart.
- A configuration module that both your application and the
better-iamCLI load, such asbetter-iam.config.mjs. See Deployment. - Access to your secret manager, your scheduler (cron, Kubernetes CronJobs, or a worker process), and your monitoring system.
A production configuration
The checklist items below refer to options in this configuration. Adapt the values, but keep every option that has a comment.
import { betterIam } from 'better-iam';
import { postgresAdapter } from 'better-iam/adapter-postgres';
import { createJsonlAuditArchive } from 'better-iam/server';
import { sendEmail, sendSms } from './delivery';
export const iam = betterIam({
// Persistent storage. Never an in-memory database.
database: postgresAdapter({ connectionString: process.env.DATABASE_URL! }),
// At least 32 characters, from your secret manager. Rotate it, never replace it.
secret: process.env.BETTER_IAM_SECRET!,
// Only during a secret rotation: the old value(s), comma-separated.
previousSecrets: process.env.BETTER_IAM_PREVIOUS_SECRETS?.split(',').filter(Boolean),
// HTTPS outside localhost.
baseURL: 'https://identity.example.com',
// Exact browser origins that call the handler. The baseURL origin is always included.
trustedOrigins: ['https://app.example.com'],
authentication: {
appName: 'Acme Cloud',
// Outbox delivery callbacks. Every email and SMS feature needs them.
sendEmail,
sendSms,
// One counter per client IP across every sign-in flow of a tenant (needs http.clientInfo).
rateLimits: { ipAttempts: 200 },
},
http: {
// Trust this header only because your own proxy sets it.
clientInfo: (request) => ({
ip: request.headers.get('x-real-ip') ?? undefined,
userAgent: request.headers.get('user-agent') ?? undefined,
}),
},
// Prometheus scrape endpoint at GET /api/iam/metrics (token of at least 24 characters).
observability: { metrics: { bearerToken: process.env.METRICS_TOKEN, gauges: true } },
// An independent, write-once copy of every tenant's audit chain.
auditArchive: createJsonlAuditArchive({ directory: '/var/lib/better-iam/audit' }),
});Runtime
- Run Node.js 22.12 or a newer supported LTS release. The authentication and protocol server does not run on edge runtimes. See Deployment.
- Install with native builds allowed: password hashing uses
argon2, and the SQLite adapter usesbetter-sqlite3. See Installation. - Keep every
@better-iam/*package on the same version. Packages are released in lockstep. See Build and release.
Secrets and key rotation
- Generate
secretfrom a cryptographic source (better-iam secret) and store it in your secret manager. It must have at least 32 characters. See Secrets and keys. - Give every instance, worker, and CLI configuration the same
secret. It seals authenticator secrets, webhook secrets, and queued deliveries, so a process with a different value cannot read them. - Write down the rotation procedure before you need it: introduce the new value in
previousSecrets(at most five), switch, runbetter-iam rotate-secretsuntil it reportsdone: true, then retire. Never replace the secret in one step. See Rotating the deployment secret. - Supply protocol keys explicitly and keep them stable across replicas and restarts: OAuth signing JWKs, cookie
keys, and encryption key; SAML keys; and
sts.jwt.signingKeysif you issue session JWTs. See Other keys. - Give services that verify stateless assertions
iam.assertionKey()(oriam.assertionKeys()during a rotation), never the secret. See Assertion keys. - Treat
BETTER_IAM_TOKENfor scheduled CLI reports as an administrator credential, scoped to a role with only the read permissions those commands need. See Nightly checks with a credential.
Database
- Use a persistent adapter: PostgreSQL, SQLite, or libSQL.
doctorreportsin-memory-databasefor:memory:. See Storage adapters. - On PostgreSQL, keep
synchronous_commit = on. Otherwise a crash can lose committed revocations, anddoctorreportspostgres-async-commit. See Durability. - On SQLite, keep the defaults (write-ahead log,
durability: 'full'). Never combinejournalMode: 'delete'withdurability: 'normal'(doctorerrorsqlite-durability). - Change IAM data only through the API, the CLI, or the adapter. Raw SQL writes can break integrity and revocation guarantees. See Data and consistency.
- Size
lockTimeoutMs(PostgreSQL) orbusyTimeoutMs(SQLite, libSQL), both 5000 ms by default, and alert onSTORAGE_BUSY. See Optimize performance.
Migrations and bootstrap
- Run
better-iam migrateas a step of every deploy, before the new release serves traffic.migrateis idempotent. See Migrations. - Plan a maintenance window for releases that add index migrations on large tables: they block IAM writes, not reads, while they run. See Upgrading an existing database.
- Bootstrap the root exactly once, with
BETTER_IAM_ROOT_EMAIL,BETTER_IAM_ROOT_NAME, andBETTER_IAM_ROOT_PASSWORDin the environment, then remove the password from the environment. See Bootstrap the root. - Enroll MFA for the root administrator before using the account, and keep root use rare. See Root and delegation.
HTTPS and sign-in addresses
- Serve
baseURLover HTTPS. HTTP is accepted only onlocalhost,127.0.0.1, and[::1]. See What every production instance sets. - Behind a proxy, set
http.clientInfoto read the client IP from the header your own proxy sets. IP allowlists, network blocks, IP-bound sessions,ipAttempts, and new-sign-in alerts depend on it. See HTTP options. - If organizations have their own addresses (
hosts.patterns), provision the wildcard DNS record and wildcard certificate, and sethosts.forwardedHost: trueonly when your proxy setsX-Forwarded-Host. See Sign-in addresses and regions. - If organizations verify custom hostnames, gate on-demand TLS certificates with
iam.hosts.allowed(hostname). See Serve a certificate. - If you configure passkeys, set
rpIDto the domain every sign-in address sits under. See Passkeys.
Cookies
- Confirm the session cookie is
__Host-better-iam.sessionin production. The__Host-prefix requires HTTPS and forbids aDomainattribute, so the cookie never reaches other subdomains. See Cookies and CSRF. - Choose
http.cookieSameSite:'lax'(default) keeps people signed in when they follow a link from email;'strict'sends the cookie on first-party requests only. See HTTP options. - Choose
http.persistentCookies:true(default) keeps the cookie for the session's remaining lifetime;falseissues browser-session cookies unless the sign-in request sendsX-Better-IAM-Persistent: 1. - Set session lifetimes and idle timeouts that match your product. See Sessions.
CORS and trusted origins
- List every browser origin that calls the handler in
trustedOrigins, exactly (scheme, host, and port). Requests from other origins fail withUNTRUSTED_ORIGIN(403). Organization addresses fromhostsare trusted automatically. See What the handler enforces. - Keep the
X-Better-IAM: 1header on JSON requests from your own code. The browser client sends it; requests without it fail withCSRF_REJECTED. - Do not add your own permissive CORS middleware in front of the handler. It answers preflights itself and sends credentialed CORS headers only to trusted origins.
Rate limits
- Review
authentication.rateLimits:attempts(10),sensitiveAttempts(5), andwindowMs(15 minutes). Counters live in the database and are shared by every instance. See Rate limits. - Turn on
ipAttemptsto stop password spraying from one address, sized for your largest office behind one NAT. It needs a recorded IP fromhttp.clientInfo. - Add request body limits and network rate limits at your ingress, in particular on public discovery routes
such as
tenants.lookupanddomains.discover. Account-level limits do not replace them. See Operational responsibilities. - Make clients honor
Retry-AfteronRATE_LIMITED(429). See RATE_LIMITED.
Scheduled jobs and sweeps
Better IAM starts no background work of its own (apart from the optional access-usage writer), so nothing below happens until your scheduler runs it. See Scheduled jobs for every job and cadence.
- Every minute:
better-iam outbox(oriam.auth.dispatchOutbox()andiam.events.dispatch()) delivers email, SMS, and webhooks and dispatches audit hooks. Runiam.events.dispatch()in the process that registers in-process subscribers. See Outbox and audit hooks. - Make delivery callbacks deduplicate by message ID, because delivery is at least once, and never log tokens or message payloads.
- Hourly:
purge, thensweep, thenreconcile. See Retention worker and Retention sweep. - On their own cadences:
digest,remind,close-certifications,monitor-invariants, anddetect-threatsif you use those features. - If you mount the OAuth provider, Shared Signals, or SCIM outbound, run their protocol jobs in the process that creates those services. They have no CLI commands. See Protocol jobs.
- With
accessUsageenabled, calliam.flushAccessUsage()before the process exits. See Access usage flush. - Never expose job functions over HTTP. They take no credential and act with the deployment's authority.
Retention
- Run
sweepwith the delivery retention your policy requires (--retention-days, default 30) and pass the same value todoctor --retention-days. See Retention sweep. - Run
purgewith the tenant retention window you want (--retention-days, default 30): deleted tenants stay in storage until it passes, then leave it for good. Audit records are preserved. See Retention worker. - Decide how long audit events stay in the database, and run
audit-pruneonly afteraudit-archive. Pruning never deletes an event the archive lacks once archiving is configured. See Audit retention. - Give contractors and temporary service accounts an
expiresAt, sopurgedisables them on time. See Operational responsibilities.
Backups and snapshots
- Back up the database with your usual tooling and test a restore. Restore whole databases, never individual rows. See Backups.
- Back up
secret,previousSecrets, and every protocol key with the database. A restored database without its secret has enrolled factors and queued messages nobody can read. - Use
better-iam store-exportandstore-import(orstore-copy) to move between databases or adapters, and protect snapshot files like the database. See Snapshots. - After a bulk import on PostgreSQL, run
VACUUM ANALYZE iam_recordsso lookups use the index immediately. See Queries and indexes.
Observability
- Point load-balancer and orchestrator probes at
GET /api/iam/health. It answers 503 when the database fails. See Health checks. - Scrape
GET /api/iam/metricswith a bearer token of at least 24 characters (doctorwarns withweak-metrics-tokenotherwise). Leavegaugesoff on very large deployments. See Prometheus metrics. - Or feed
observability.onSpanto your own tracer or metrics library. See Spans. - Pass
X-Request-Idfrom your gateway so IAM'shttpspans join your request logs. See Request IDs. - Alert on
RATE_LIMITED,IP_BLOCKED,STORAGE_BUSY, abandoned deliveries, anddoctorwarnings. See What to watch. - Run
better-iam doctor --stricton a schedule as well as after deploys, so a stopped job pages someone. See Doctor.
Audit chain
- Configure
auditArchiveand runbetter-iam audit-archiveevery few minutes. See Continuous audit archiving. - Store archives where an attacker with database access cannot rewrite them.
createJsonlAuditArchivewrites files that are never replaced. - Alert when
audit-archiveexits non-zero (AUDIT_ARCHIVE_FAILED). Its output lists each failed tenant withAUDIT_CHAIN_BROKEN,ARCHIVE_WRITE_FAILED, orARCHIVE_CONFLICT.doctorreportsaudit-archive-behindwhen a tenant has unarchived events older than a day. - Give auditors
better-iam audit-verify-archive, which checks an archive without the database. See Audit chain. - Forward denials and
binding:*events to your SIEM with a webhook or subscriber. See Webhooks.
Security headers
- Rely on the handler for its own JSON responses: every one carries
Cache-Control: no-store,X-Content-Type-Options: nosniff, andReferrer-Policy: no-referrer. See What the handler enforces. - Set
Strict-Transport-Securityat your edge. Better IAM does not set it. - Set a
Content-Security-Policyand frame protection (frame-ancestors) on the pages your application serves, in particular sign-in pages. Better IAM does not render those pages, so it cannot set these headers. - Keep request bodies bounded at the ingress too. The handler caps IAM request bodies at 64 KiB
(
PAYLOAD_TOO_LARGE, 413).
Upgrades
- Read the changelog and the Upgrade guides before moving to a new version.
- Upgrade every
@better-iam/*package together, then runmigratebefore the new release serves traffic.doctorreportsschema-behindwhile the database lacks a step of the running release. - During a rolling upgrade, instances of the previous release keep working; a migration waits up to ten minutes for another instance's migration to finish. See Upgrading an existing database.
- Re-run the test suite of your own policies and integrations against the new version. See Testing.
Wire the checks into your deploy
Migrate, then gate on doctor
Run both commands before the new release takes traffic. doctor --strict exits non-zero (DOCTOR_FINDINGS) on any
error or warning, so a broken release stops here.
set -euo pipefail
CONFIG=/etc/better-iam/better-iam.config.mjs
better-iam migrate --config "$CONFIG"
better-iam doctor --config "$CONFIG" --strict --retention-days 30Schedule the jobs
CONFIG=/etc/better-iam/better-iam.config.mjs
* * * * * better-iam outbox --config $CONFIG
*/5 * * * * better-iam audit-archive --config $CONFIG
0 * * * * better-iam purge --config $CONFIG && better-iam sweep --config $CONFIG && better-iam reconcile --config $CONFIG --fail-on-attention
15,30,45 * * * * better-iam reconcile --config $CONFIG --fail-on-attention
30 * * * * better-iam close-certifications --config $CONFIG && better-iam monitor-invariants --config $CONFIG
0 7 * * * better-iam digest --config $CONFIG && better-iam remind --config $CONFIG && better-iam outbox --config $CONFIG
*/15 * * * * better-iam doctor --config $CONFIG --strict --retention-days 30 > /dev/null || alert-oncall "better-iam doctor"alert-oncall stands for your own alerting command.
Probe health and metrics
curl -fsS https://identity.example.com/api/iam/health
curl -fsS -H "Authorization: Bearer $METRICS_TOKEN" https://identity.example.com/api/iam/metrics | headVerify it works
better-iam doctor --config better-iam.config.mjs --strictexits 0 and its JSON shows"ok": true, every migration of the release among the applied migrations understorage, and no error or warning findings.GET /api/iam/healthanswers{ "status": "ok", "database": "ok", ... }.GET /api/iam/metricsanswers 401 without the token and Prometheus text with it.- Signing in from your application origin sets
__Host-better-iam.sessionwithSecure,HttpOnly, andSameSite=Lax(orStrict). - A password reset email arrives within a minute or two, which proves the outbox job runs.
better-iam audit-verify-archive --directory /var/lib/better-iam/audit --tenant TENANT_IDexits 0.
Troubleshooting
Next steps
Runtime requirements, environment variables, and the deploy sequence.
Secrets and keysWhat the secret protects and the four-stage rotation.
Scheduled jobsEvery job, its CLI command, and its cadence.
ObservabilitySpans, Prometheus metrics, health checks, and alerts.
Optimize performanceKeep authorization checks and storage fast as data grows.
Security modelTrust boundaries and the responsibilities that stay with you.
Better IAM is created by Sean Filimon
Last updated
Test your integration
Test code that uses Better IAM with an in-memory SQLite instance, a reusable fixture, real sessions in route handlers, the NestJS testing stand-in, and Playwright end-to-end sign-in.
Optimize performance
Measure where Better IAM spends time, then keep authorization checks, storage lookups, and background work fast with batching, query plans, indexes, retention, and offline token verification.