# Production checklist (/docs/guides/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 `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](/docs/operations) walks through the same ground as a sequence of steps. Use this page to
track what is done.

## Prerequisites [#prerequisites]

* A Better IAM instance that already works in development. If you do not have one, start with the
  [Quickstart](/docs/quickstart).
* A configuration module that both your application and the `better-iam` CLI load, such as
  `better-iam.config.mjs`. See [Deployment](/docs/operations/deployment#create-the-configuration-first-time-only).
* Access to your secret manager, your scheduler (cron, Kubernetes CronJobs, or a worker process), and your
  monitoring system.

## A production configuration [#a-production-configuration]

The checklist items below refer to options in this configuration. Adapt the values, but keep every option that has
a comment.

```ts title="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 [#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](/docs/operations/deployment#runtime).
* [ ] Install with native builds allowed: password hashing uses `argon2`, and the SQLite adapter uses
  `better-sqlite3`. See [Installation](/docs/installation).
* [ ] Keep every `@better-iam/*` package on the same version. Packages are released in lockstep. See
  [Build and release](/docs/operations/deployment/releases).

## Secrets and key rotation [#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](/docs/operations/deployment/secrets#what-the-secret-protects).
* [ ] 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](/docs/operations/deployment/secrets#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](/docs/operations/deployment/secrets#other-keys).
* [ ] Give services that verify stateless assertions `iam.assertionKey()` (or `iam.assertionKeys()` during a
  rotation), never the secret. See [Assertion keys](/docs/operations/deployment/secrets#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](/docs/operations/jobs#nightly-checks-with-a-credential).

## Database [#database]

* [ ] Use a persistent adapter: PostgreSQL, SQLite, or libSQL. `doctor` reports `in-memory-database` for
  `:memory:`. See [Storage adapters](/docs/databases/operations).
* [ ] On PostgreSQL, keep `synchronous_commit = on`. Otherwise a crash can lose committed revocations, and `doctor`
  reports `postgres-async-commit`. See [Durability](/docs/operations/deployment/database#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](/docs/concepts/data-and-consistency#serialized-transactions).
* [ ] Size `lockTimeoutMs` (PostgreSQL) or `busyTimeoutMs` (SQLite, libSQL), both 5000 ms by default, and alert
  on `STORAGE_BUSY`. See [Optimize performance](/docs/guides/performance).

## Migrations and bootstrap [#migrations-and-bootstrap]

* [ ] Run `better-iam migrate` as a step of every deploy, before the new release serves traffic. `migrate` is
  idempotent. See [Migrations](/docs/operations/deployment/database#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](/docs/operations/deployment/database#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](/docs/operations/deployment#bootstrap-the-root-once).
* [ ] Enroll MFA for the root administrator before using the account, and keep root use rare. See
  [Root and delegation](/docs/operations/security#root-and-delegation).

## HTTPS and sign-in addresses [#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](/docs/operations/deployment#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](/docs/operations/deployment/configuration#http).
* [ ] 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](/docs/operations/deployment/hosts-and-regions#organization-subdomains).
* [ ] If organizations verify custom hostnames, gate on-demand TLS certificates with `iam.hosts.allowed(hostname)`.
  See [Serve a certificate](/docs/operations/deployment/hosts-and-regions#serve-a-certificate).
* [ ] If you configure passkeys, set `rpID` to the domain every sign-in address sits under. See
  [Passkeys](/docs/authentication/passkeys).

## Cookies [#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](/docs/operations/security#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](/docs/operations/deployment/configuration#http).
* [ ] 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](/docs/authentication/sessions#lifetimes).

## CORS and trusted origins [#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](/docs/operations/deployment/protocol-mounts#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 [#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](/docs/operations/deployment/configuration#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](/docs/operations/security#operational-responsibilities).
* [ ] Make clients honor `Retry-After` on `RATE_LIMITED` (429). See
  [RATE\_LIMITED](/docs/reference/errors#rate_limited).

## Scheduled jobs and sweeps [#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](/docs/operations/jobs#at-a-glance) 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](/docs/operations/jobs#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](/docs/operations/jobs#retention-worker)
  and [Retention sweep](/docs/operations/jobs#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](/docs/operations/jobs#protocol-jobs).
* [ ] With `accessUsage` enabled, call `iam.flushAccessUsage()` before the process exits. See
  [Access usage flush](/docs/operations/jobs#access-usage-flush).
* [ ] Never expose job functions over HTTP. They take no credential and act with the deployment's authority.

## Retention [#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](/docs/operations/jobs#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](/docs/operations/jobs#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](/docs/operations/jobs#audit-retention).
* [ ] Give contractors and temporary service accounts an `expiresAt`, so `purge` disables them on time. See
  [Operational responsibilities](/docs/operations/security#operational-responsibilities).

## Backups and snapshots [#backups-and-snapshots]

* [ ] Back up the database with your usual tooling and test a restore. Restore whole databases, never individual
  rows. See [Backups](/docs/operations/deployment/database#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](/docs/databases/operations#snapshots-and-moving-between-databases).
* [ ] After a bulk import on PostgreSQL, run `VACUUM ANALYZE iam_records` so lookups use the index immediately.
  See [Queries and indexes](/docs/operations/deployment/database#queries-and-indexes).

## Observability [#observability]

* [ ] Point load-balancer and orchestrator probes at `GET /api/iam/health`. It answers 503 when the database fails.
  See [Health checks](/docs/operations/observability#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](/docs/operations/observability#prometheus-metrics).
* [ ] Or feed `observability.onSpan` to your own tracer or metrics library. See
  [Spans](/docs/operations/observability#spans).
* [ ] Pass `X-Request-Id` from your gateway so IAM's `http` spans join your request logs. See
  [Request IDs](/docs/operations/observability#request-ids).
* [ ] Alert on `RATE_LIMITED`, `IP_BLOCKED`, `STORAGE_BUSY`, abandoned deliveries, and `doctor` warnings. See
  [What to watch](/docs/operations/observability#what-to-watch).
* [ ] Run `better-iam doctor --strict` on a schedule as well as after deploys, so a stopped job pages someone. See
  [Doctor](/docs/databases/operations#doctor).

## Audit chain [#audit-chain]

* [ ] Configure `auditArchive` and run `better-iam audit-archive` every few minutes. See
  [Continuous audit archiving](/docs/operations/jobs#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](/docs/concepts/events/audit-chain#verify-an-archive-anywhere).
* [ ] Forward denials and `binding:*` events to your SIEM with a webhook or subscriber. See
  [Webhooks](/docs/concepts/events/webhooks).

## Security headers [#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](/docs/operations/deployment/protocol-mounts#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 [#upgrades]

* [ ] Read the changelog and the [Upgrade guides](/docs/guides/upgrade) 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](/docs/operations/deployment/database#upgrading-an-existing-database).
* [ ] Re-run the test suite of your own policies and integrations against the new version. See
  [Testing](/docs/guides/testing).

## Wire the checks into your deploy [#wire-the-checks-into-your-deploy]

      ### Migrate, then gate on doctor [#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.

    ```sh title="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 [#schedule-the-jobs]

    ```sh title="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 [#probe-health-and-metrics]

    ```sh
    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 [#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 [#troubleshooting]

  #### doctor reports schema-behind

    The database lacks a migration of the running release. Run `better-iam migrate` with the same configuration the
    application uses, then run `doctor` again. See [Migrations](/docs/operations/deployment/database#migrations).
  
  #### doctor reports weak-secret or unreadable-secrets

    `weak-secret` means the secret looks like a placeholder or has little variety: rotate it to a generated value
    through `previousSecrets`. `unreadable-secrets` means stored values open with no configured secret, usually because
    the secret was replaced outright: put the old value back in `previousSecrets`. See
    [Secrets and keys](/docs/operations/deployment/secrets).
  
  #### doctor reports outbox-stalled, sweep-backlog, or purge-not-running

    A scheduled job is not running, or runs against a different configuration. Check the scheduler's logs and run the
    command by hand with the same `--config`. See [Scheduled jobs](/docs/operations/jobs).
  
  #### Browser calls fail with UNTRUSTED_ORIGIN or CSRF_REJECTED

    `UNTRUSTED_ORIGIN` means the page's origin is not in `trustedOrigins` exactly (check the scheme and port).
    `CSRF_REJECTED` means a JSON request lacked `Content-Type: application/json` or `X-Better-IAM: 1`, or a cookie
    request lacked an `Origin` header. See [UNTRUSTED\_ORIGIN](/docs/reference/errors#untrusted_origin) and
    [CSRF\_REJECTED](/docs/reference/errors#csrf_rejected).
  
  #### A whole office is rate limited

    `ipAttempts` counts every attempt from one client IP per tenant. Raise it to fit the largest group of people behind
    one NAT. `identities.unlock` clears a person's counters but never the network's. See
    [Rate limits](/docs/operations/security#rate-limits).
  
  #### Requests fail with STORAGE_BUSY

    The database could not grant its lock in time, and the whole transaction rolled back. Retry the operation, look for
    long transactions such as large imports, and raise `lockTimeoutMs` or `busyTimeoutMs` if needed. See
    [STORAGE\_BUSY](/docs/reference/errors#storage_busy) and [Optimize performance](/docs/guides/performance).
  
## Next steps [#next-steps]

  - [Deployment](/docs/operations/deployment): Runtime requirements, environment variables, and the deploy sequence.

  - [Secrets and keys](/docs/operations/deployment/secrets): What the secret protects and the four-stage rotation.

  - [Scheduled jobs](/docs/operations/jobs): Every job, its CLI command, and its cadence.

  - [Observability](/docs/operations/observability): Spans, Prometheus metrics, health checks, and alerts.

  - [Optimize performance](/docs/guides/performance): Keep authorization checks and storage fast as data grows.

  - [Security model](/docs/operations/security): Trust boundaries and the responsibilities that stay with you.
