BetterIAM

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/server@better-iam/cli@better-iam/core@better-iam/adapter-postgres@better-iam/adapter-sqlite@better-iam/adapter-libsqldeployment.mdsecurity.mdhttp.tsself-check.tsretention.tsaudit-archive.tshosts.tssql.tsindex.tsindex.tsindex.ts

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 doctor watching 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-iam CLI load, such as better-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.

lib/iam.ts
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 uses better-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 secret from 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, run better-iam rotate-secrets until it reports done: 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.signingKeys if you issue session JWTs. See Other keys.
  • Give services that verify stateless assertions iam.assertionKey() (or iam.assertionKeys() during a rotation), never the secret. See Assertion keys.
  • Treat BETTER_IAM_TOKEN for 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. doctor reports in-memory-database for :memory:. See Storage adapters.
  • On PostgreSQL, keep synchronous_commit = on. Otherwise a crash can lose committed revocations, and doctor reports postgres-async-commit. See Durability.
  • On SQLite, keep the defaults (write-ahead log, durability: 'full'). Never combine journalMode: 'delete' with durability: 'normal' (doctor error sqlite-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) or busyTimeoutMs (SQLite, libSQL), both 5000 ms by default, and alert on STORAGE_BUSY. See Optimize performance.

Migrations and bootstrap

  • Run better-iam migrate as a step of every deploy, before the new release serves traffic. migrate is 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, and BETTER_IAM_ROOT_PASSWORD in 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 baseURL over HTTPS. HTTP is accepted only on localhost, 127.0.0.1, and [::1]. See What every production instance sets.
  • Behind a proxy, set http.clientInfo to 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 set hosts.forwardedHost: true only when your proxy sets X-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 rpID to the domain every sign-in address sits under. See Passkeys.

Cookies

  • Confirm the session cookie is __Host-better-iam.session in production. The __Host- prefix requires HTTPS and forbids a Domain attribute, 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; false issues browser-session cookies unless the sign-in request sends X-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 with UNTRUSTED_ORIGIN (403). Organization addresses from hosts are trusted automatically. See What the handler enforces.
  • Keep the X-Better-IAM: 1 header on JSON requests from your own code. The browser client sends it; requests without it fail with CSRF_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), and windowMs (15 minutes). Counters live in the database and are shared by every instance. See Rate limits.
  • Turn on ipAttempts to stop password spraying from one address, sized for your largest office behind one NAT. It needs a recorded IP from http.clientInfo.
  • Add request body limits and network rate limits at your ingress, in particular on public discovery routes such as tenants.lookup and domains.discover. Account-level limits do not replace them. See Operational responsibilities.
  • Make clients honor Retry-After on RATE_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 (or iam.auth.dispatchOutbox() and iam.events.dispatch()) delivers email, SMS, and webhooks and dispatches audit hooks. Run iam.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, then sweep, then reconcile. See Retention worker and Retention sweep.
  • On their own cadences: digest, remind, close-certifications, monitor-invariants, and detect-threats if 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 accessUsage enabled, call iam.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 sweep with the delivery retention your policy requires (--retention-days, default 30) and pass the same value to doctor --retention-days. See Retention sweep.
  • Run purge with 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-prune only after audit-archive. Pruning never deletes an event the archive lacks once archiving is configured. See Audit retention.
  • Give contractors and temporary service accounts an expiresAt, so purge disables 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-export and store-import (or store-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_records so 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/metrics with a bearer token of at least 24 characters (doctor warns with weak-metrics-token otherwise). Leave gauges off on very large deployments. See Prometheus metrics.
  • Or feed observability.onSpan to your own tracer or metrics library. See Spans.
  • Pass X-Request-Id from your gateway so IAM's http spans join your request logs. See Request IDs.
  • Alert on RATE_LIMITED, IP_BLOCKED, STORAGE_BUSY, abandoned deliveries, and doctor warnings. See What to watch.
  • Run better-iam doctor --strict on a schedule as well as after deploys, so a stopped job pages someone. See Doctor.

Audit chain

  • Configure auditArchive and run better-iam audit-archive every few minutes. See Continuous audit archiving.
  • Store archives where an attacker with database access cannot rewrite them. createJsonlAuditArchive writes files that are never replaced.
  • Alert when audit-archive exits non-zero (AUDIT_ARCHIVE_FAILED). Its output lists each failed tenant with AUDIT_CHAIN_BROKEN, ARCHIVE_WRITE_FAILED, or ARCHIVE_CONFLICT. doctor reports audit-archive-behind when 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, and Referrer-Policy: no-referrer. See What the handler enforces.
  • Set Strict-Transport-Security at your edge. Better IAM does not set it.
  • Set a Content-Security-Policy and 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 run migrate before the new release serves traffic. doctor reports schema-behind while 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.

deploy.sh
set -euo pipefail
CONFIG=/etc/better-iam/better-iam.config.mjs
better-iam migrate --config "$CONFIG"
better-iam doctor --config "$CONFIG" --strict --retention-days 30

Schedule the jobs

crontab
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 | head

Verify it works

  • better-iam doctor --config better-iam.config.mjs --strict exits 0 and its JSON shows "ok": true, every migration of the release among the applied migrations under storage, and no error or warning findings.
  • GET /api/iam/health answers { "status": "ok", "database": "ok", ... }.
  • GET /api/iam/metrics answers 401 without the token and Prometheus text with it.
  • Signing in from your application origin sets __Host-better-iam.session with Secure, HttpOnly, and SameSite=Lax (or Strict).
  • 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_ID exits 0.

Troubleshooting

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page