BetterIAM

Options

Every option betterIam() accepts, grouped by area, with its type, default, allowed range, and a short example, read from the server's option types.

This page lists every option of BetterIamOptions, the object you pass to betterIam(). Each option has its own heading with its type, default, the range enforced at startup, and a short example. Use it as a lookup table; for advice on which options a production deployment changes, read the Configuration reference.

Three options are required: database, secret, and baseURL. Every other option is optional and has a safe default. Durations are in milliseconds unless the option name ends in Seconds or Days.

A complete configuration

The examples below each show only the option they describe. A real instance always carries the three required options, as in this minimal configuration:

iam.ts
import { betterIam } from 'better-iam';
import { sqliteAdapter } from 'better-iam/adapter-sqlite';

export const iam = betterIam({
  database: sqliteAdapter({ filename: './iam.db' }),
  secret: process.env.BETTER_IAM_SECRET!, // at least 32 characters
  baseURL: process.env.BETTER_IAM_BASE_URL!, // https, or http on localhost
});

When the CLI must load the same options, put them in better-iam.config.mjs (or .ts) as the default export. defineConfig from better-iam/server types the object, or a factory that receives { command, env, cwd }, and returns it unchanged:

better-iam.config.ts
import { defineConfig } from 'better-iam/server';
import { postgresAdapter } from 'better-iam/adapter-postgres';

export default defineConfig(({ env = process.env } = {}) => ({
  database: postgresAdapter({ connectionString: env.DATABASE_URL! }),
  secret: env.BETTER_IAM_SECRET!,
  baseURL: env.BETTER_IAM_BASE_URL ?? 'http://localhost:3000',
}));

In your server, call betterIam(await configOptions(config)) (also from better-iam/server) to turn a factory into options. The CLI also reads a named cli export from the same module (flag defaults for its commands); that export is CLI configuration, not a betterIam() option. See CLI.

How options are validated

betterIam() validates the whole configuration once, when it runs. A missing required option, a value outside its range, or an unknown key in a strict group (clearances) throws an IamError with code INVALID_CONFIG before the instance exists. The message names the field, for example sts.maxRoleSessionSeconds must be an integer from 900 to 43200 or vault.maxValueBytes must be an integer from 16 to 1048576. Because a misconfiguration fails at startup, it never surfaces on the first request instead.

Options that switch on an optional module (inference, ssh, verifiableCredentials, clearances, a2a) leave it off when absent. Calling one of its API methods on a deployment where it is off fails with FEATURE_DISABLED (403).

AreaOptions
Coredatabase, secret, previousSecrets, baseURL, basePath, trustedOrigins
Authenticationauthentication (with rateLimits and passwordPolicy), http
Tenancy and cataloghierarchy, permissions, onboarding, tenantDefaults, domains, hosts, regions
Application callbacksresolveResource, resolveContext
Access workflowsaccessRequests, accessUsage
Events, audit, and telemetryevents, auditArchive, observability
Extensions and protocolsplugins, protocols
Temporary credentialssts (with jwt and webIdentity)
Optional modulesinference, a2a, billing, ssh, verifiableCredentials, clearances, vault, signals

Core

The core options say where data is stored, which secret protects it, and where the deployment is reachable.

database

TypeDefaultRequired
IamStorenoneyes

The storage adapter every IAM record is written to. Use postgresAdapter from better-iam/adapter-postgres, sqliteAdapter from better-iam/adapter-sqlite, libsqlAdapter from better-iam/adapter-libsql, or your own implementation of the IamStore contract. A missing value fails with INVALID_CONFIG (database is required). See Databases.

iam.ts
import { betterIam } from 'better-iam';
import { postgresAdapter } from 'better-iam/adapter-postgres';

export const iam = betterIam({
  database: postgresAdapter({ connectionString: process.env.DATABASE_URL! }),
  // ...secret, baseURL
});

secret

TypeDefaultRequired
stringnoneyes, at least 32 characters

The deployment secret. It encrypts the secrets Better IAM stores (authenticator secrets, webhook secrets, pending outbox deliveries, and modules' sealed values such as vault secrets, KMS key material, SSH CA keys, and inference provider keys), keys the digests of one-time challenges, and derives the service-assertion signing key. Keep it stable across restarts and processes: replacing it outright makes enrolled factors and webhook secrets unusable. Rotate it with previousSecrets. It does not sign session JWTs, which use sts.jwt.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  secret: process.env.BETTER_IAM_SECRET!,
  // ...database, baseURL
});

previousSecrets

TypeDefaultRange
string[]noneat most 5 distinct values, each at least 32 characters, none equal to secret

Deployment secrets being rotated out. Everything they sealed or signed keeps working while new values use secret. iam.rotateSecrets() re-seals stored values with the current secret, after which you remove the old ones. See Secrets and keys.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  secret: process.env.BETTER_IAM_SECRET!,
  previousSecrets: [process.env.BETTER_IAM_PREVIOUS_SECRET!],
  // ...database, baseURL
});

baseURL

TypeDefaultRequired
stringnoneyes

The public URL people and clients reach the deployment at. It must use https:, except on the loopback hosts localhost, 127.0.0.1, and [::1] (INVALID_CONFIG: HTTPS is required outside localhost). Its origin is always a trusted origin. On HTTPS the session cookie is named __Host-better-iam.session; on plain HTTP it is better-iam.session.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  baseURL: 'https://identity.example.com',
  // ...database, secret
});

basePath

TypeDefault
string'/api/iam'

The URL path the HTTP handler answers under. It must start with /, contain only letters, digits, underscores, hyphens, and slashes, and not end with / (INVALID_CONFIG: Invalid basePath). Browser clients and framework integrations must use the same path.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  basePath: '/auth',
  // ...database, secret, baseURL
});

trustedOrigins

TypeDefault
string[]the baseURL origin only

Additional exact origins (scheme, host, and port) allowed to call the handler from a browser. Add your application's origin when it differs from baseURL, for example https://app.example.com calling https://identity.example.com. Each entry must be an exact origin with no path (INVALID_CONFIG: Trusted origins must be exact origins). Browser requests from any other Origin are refused with UNTRUSTED_ORIGIN.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  baseURL: 'https://identity.example.com',
  trustedOrigins: ['https://app.example.com'],
  // ...database, secret
});

Authentication

authentication

TypeDefault
Omit<AuthOptions, 'store' | 'secret' | 'previousSecrets' | 'baseURL' | 'trustedOrigins'>{}

How people sign in, how sessions behave, and how email and SMS leave the system. store, secret, previousSecrets, baseURL, and trustedOrigins come from the top level. The server wires onAudit and deliverWebhook itself; use events to receive events or replace the webhook transport.

OptionTypeDefaultDescription
appNamestring'Better IAM'Your product name as the issuer in authenticator-app labels and the default passkey rpName. Built-in email templates take their own appName (TemplateOptions in better-iam/auth/templates).
signUpEnabledbooleanfalseLets anyone create an account with email and password.
requireEmailVerificationbooleansignUpEnabledRefuses sign-in until the address is verified. Needs sendEmail.
emailPasswordbooleantruePassword sign-in, sign-up, and reset. false disables all three.
passwordlessEmailbooleanfalseMagic links and one-time email codes. Needs sendEmail.
passwordlessSmsbooleanfalseOne-time SMS codes. Needs sendSms.
sendEmail(message: DeliveryMessage) => Promise<void>noneYour email transport, called by the outbox outside the write transaction. Throw to retry; deduplicate by message.id.
sendSms(message: DeliveryMessage) => Promise<void>noneYour SMS transport, with the same contract as sendEmail.
maxDeliveryAttemptsnumber25Failed attempts (1 to 1000) before the outbox abandons a message.
sessionLifetimeMsnumber7 daysAbsolute session lifetime, one minute to thirty days.
sessionIdleTimeoutMsnumberthe smaller of the lifetime and 1 dayIdle timeout, one minute to the absolute lifetime.
recentAuthenticationMsnumber5 minutesHow long a sign-in counts as recent for sensitive operations, one second to fifteen minutes.
trustedDeviceLifetimeMsnumber30 daysHow long "remember this device" skips MFA. 0 disables it; otherwise one minute to 365 days.
signInNotificationsbooleanfalseQueues a new-sign-in email when a session starts from a client none of the person's live sessions or remembered devices has used. Tenants override it with notifyNewSignIn.
mfaEmailCodesbooleanfalseLets people without an authenticator satisfy MFA with an emailed one-time code. Never applies to root administrators. Tenants override it.
failedSignInAlertsnumber0Emails a sign-in-failures alert after this many failed attempts since the last sign-in. 0 disables it; at most 1000; needs sendEmail.
rateLimitsRateLimitOptionssee belowAttempt limits for authentication flows.
requireMfa(tenant: Tenant, identity: Identity) => boolean | Promise<boolean>noneA deployment-wide rule deciding who must complete MFA. Tenant policies add to it.
passkeys{ rpID: string; rpName?: string }offEnables WebAuthn passkeys. rpID must equal, or be a parent domain of, the host of every trusted origin. rpName defaults to appName.
passwordPolicyPasswordPolicyOptionssee belowDeployment-wide password screening.
now() => numberDate.nowReplaces the clock, for deterministic tests. The whole server uses it.

DeliveryMessage carries id, tenantId, to, template, payload (string values), and, when hosts or regions are configured, signInUrl with the organization's own sign-in address.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  authentication: {
    appName: 'Acme',
    passwordlessEmail: true,
    sessionLifetimeMs: 24 * 60 * 60_000,
    passkeys: { rpID: 'example.com' },
    sendEmail: async (message) => {
      await mailer.send({ id: message.id, to: message.to, template: message.template, data: message.payload });
    },
  },
  // ...database, secret, baseURL
});

mailer stands for your own email provider client. See Email and SMS delivery and Sessions.

authentication.rateLimits

OptionTypeDefaultDescription
attemptsnumber10Attempts per window for ordinary flows such as password sign-in (1 to 100000).
sensitiveAttemptsnumber5Attempts per window for MFA, recovery, and delivery requests (1 to 100000).
windowMsnumber15 minutesThe rolling window, one second to 24 hours.
ipAttemptsnumber0Attempts per window from one client IP across every authentication flow of a tenant (0 to 1000000; 0 disables it). Needs a recorded IP from http.clientInfo.
limiterRateLimitercounters in the IAM databaseReplaces the counter store. Implement consume(), and optionally reset() so administrators can unlock accounts.

A refused attempt fails with RATE_LIMITED (429). createMemoryRateLimiter() and createStoreRateLimiter(store) from better-iam/auth are the built-in limiters; the in-memory one suits single-process deployments and tests.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  authentication: {
    rateLimits: { attempts: 5, windowMs: 10 * 60_000, ipAttempts: 200 },
  },
  // ...database, secret, baseURL
});

authentication.passwordPolicy

OptionTypeDefaultDescription
blockCommonPasswordsbooleantrueRejects well-known, keyboard-walk, sequential, and low-variety passwords from a short built-in list.
isBreached(password: string) => Promise<boolean>noneReturns true when the password appears in a breach corpus.
check(password, { tenantId, identity? }) => string | undefined | Promise<string | undefined>noneYour own rule: return a message to reject the password, or nothing to accept it.

Passwords are hashed with Argon2id and need at least 12 characters whatever this policy says; tenant policies add their own rules (minPasswordLength, passwordHistory, and others) on top. pwnedPasswords() from better-iam/auth is a Have I Been Pwned client for isBreached that sends only a five-character SHA-1 prefix.

iam.ts
import { betterIam } from 'better-iam';
import { pwnedPasswords } from 'better-iam/auth';

export const iam = betterIam({
  authentication: {
    passwordPolicy: {
      isBreached: pwnedPasswords(),
      check: (password) => (/acme/i.test(password) ? 'Do not use the company name' : undefined),
    },
  },
  // ...database, secret, baseURL
});

http

OptionTypeDefaultDescription
clientInfo(request: Request) => SessionClientInfo | undefinedrecords the User-Agent header onlyDerives the client details (ip, userAgent, label) recorded on sessions a request issues.
cookieSameSite'lax' | 'strict''lax'SameSite of the session and device cookies.
persistentCookiesbooleantruetrue sets the cookie Max-Age to the session's remaining lifetime; false issues browser-session cookies. A request overrides it with the X-Better-IAM-Persistent: 1 or 0 header.

Read the client IP only from a header that a proxy you control sets; never trust X-Forwarded-For otherwise. Tenant IP allowlists, IP-bound sessions, rateLimits.ipAttempts, and new-sign-in notifications all depend on a recorded IP. Invalid values fail with http.cookieSameSite must be lax or strict or http.persistentCookies must be a boolean.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  http: {
    clientInfo: (request) => ({
      ip: request.headers.get('x-real-ip') ?? undefined, // set by your own reverse proxy
      userAgent: request.headers.get('user-agent') ?? undefined,
    }),
    cookieSameSite: 'strict',
  },
  // ...database, secret, baseURL
});

See HTTP handler.

Tenancy and catalog

These options describe the shape of your product: which kinds of tenants exist, which actions and resource types policies can name, how people join, and what every new tenant starts with.

hierarchy

TypeDefault
{ types: Record<string, { allowedChildren: string[] }>; maxDepth?: number }root → organization → project, maxDepth 8

The kinds of tenants and which children each may have. It must define root; every allowed child must be a defined type other than root; maxDepth is 1 to 100.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  hierarchy: {
    types: {
      root: { allowedChildren: ['reseller'] },
      reseller: { allowedChildren: ['customer'] },
      customer: { allowedChildren: ['workspace'] },
      workspace: { allowedChildren: [] },
    },
    maxDepth: 6,
  },
  // ...database, secret, baseURL
});

See Tenants and identities.

permissions

OptionTypeDefaultDescription
mode'catalog' | 'tenant-defined''catalog'catalog: only you declare actions and resource types. tenant-defined: tenants may also register their own.
actionsstring[]noneProduct actions not tied to a declared resource type, such as billing:export.
resourceTypesRecord<string, ResourceTypeDefinition>noneYour resource types. Declaring any makes policy validation check resource patterns against them.
identityAttributesRecord<string, 'string' | 'number' | 'boolean'>noneTyped attributes administrators may set on identities, exposed to policies as principal.{name}. Names cannot shadow built-in principal keys.

A ResourceTypeDefinition has:

FieldTypeDescription
descriptionstringShown to administrators.
actionsstring[]Fully qualified actions for this type; they join the permission catalog.
attributesRecord<string, 'string' | 'number' | 'boolean'>Attribute schema exposed to conditions as resource.{name}.
parentstringParent resource type, for hierarchical resources.
managedbooleanRegistered with IAM (resources.register) and resolved from its registry instead of resolveResource.
relationsstring[]Relation names (such as viewer, editor) identities and groups may hold on resources of this type.
iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  permissions: {
    actions: ['billing:export'],
    resourceTypes: {
      document: {
        actions: ['documents:read', 'documents:write'],
        attributes: { ownerId: 'string' },
        relations: ['viewer', 'editor'],
      },
      project: { managed: true, actions: ['projects:read', 'projects:manage'] },
    },
    identityAttributes: { department: 'string' },
  },
  // ...database, secret, baseURL
});

See The permission catalog.

onboarding

OptionTypeDefaultDescription
mode'invitation' | 'linked''invitation'invitation creates a separate identity per tenant. linked also lets people connect accounts they hold in several tenants through an explicit, recently authenticated link.
invitationLifetimeMsnumber1 day (86400000)How long owner and member invitation links stay valid.
iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  onboarding: { mode: 'linked', invitationLifetimeMs: 7 * 86_400_000 },
  // ...database, secret, baseURL
});

tenantDefaults

OptionTypeDefaultDescription
limitsTenantLimitsnonePlan limits stamped on every tenant tenants.create creates: identities, serviceAccounts, agents, groups, roles, policies, resources, webhooks.
authPolicyTenantAuthPolicynoneAn authentication policy stamped on every new tenant, such as { requireMfa: true }.

Both are validated at construction; errors are prefixed with tenantDefaults:. Creating past a limit fails with LIMIT_EXCEEDED. See Tenant authentication policy for every TenantAuthPolicy field.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  tenantDefaults: {
    limits: { identities: 50, serviceAccounts: 5, webhooks: 3 },
    authPolicy: { requireMfa: true, sessionLifetimeMs: 12 * 60 * 60_000 },
  },
  // ...database, secret, baseURL
});

domains

OptionTypeDefaultDescription
resolveTxt(hostname: string) => Promise<string[][]>resolveTxt from node:dns/promisesThe DNS TXT lookup that proves control of an email domain or custom hostname.
recordNamestring'_better-iam-challenge'The label under the domain that holds the TXT record. Must be a single DNS label.
blockedDomainsstring[]widely used consumer mailbox providers (gmail.com, outlook.com, ...)Domains no tenant may claim, compared exactly. Supplying it replaces the default list.

These settings serve the domains API group (verified email domains and home-realm discovery) and custom hostnames.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  domains: { recordName: '_acme-verify' },
  // ...database, secret, baseURL
});

See Enterprise onboarding.

hosts

OptionTypeDefaultDescription
patternsstring[][]Up to 16 hostname templates. Each contains {tenant} (the organization's alias) exactly once and {region} at most once. The first is the canonical address used in sign-in URLs and email links.
customHostnamesbooleanfalseLets organizations verify hostnames of their own with a DNS TXT record (hostnames API group).
cnameTargetstringnoneThe hostname organizations point their custom hostname at with a CNAME, shown in the setup instructions hostnames.add returns.
signInPathstring'/'The path of your sign-in page on an organization's address.
forwardedHostbooleanfalseReads the address from X-Forwarded-Host before Host. Enable it only behind a proxy you control.

Requests on an organization's address are pinned to that organization. A {region} pattern needs the regions option, patterns need an HTTPS baseURL outside localhost, and with passkeys configured every pattern must sit under the passkey rpID.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  hosts: {
    patterns: ['{tenant}.signin.example.com'],
    customHostnames: true,
    cnameTarget: 'custom.signin.example.com',
    signInPath: '/sign-in',
  },
  // ...database, secret, baseURL
});

See Sign-in addresses and regions.

regions

OptionTypeDefaultDescription
currentstringrequiredThis deployment's region, such as 'us-east-1'. Must be one of regions.
regionsRecord<string, { baseURL?: string; label?: string }>required1 to 64 regions with the base URL of each region's deployment (HTTPS outside localhost) and a display label (1 to 64 characters).
locate(alias: string) => Promise<string | undefined>noneFor regions with separate databases: returns the region an organization alias lives in when this region's database does not know it.

Without regions the deployment is single-region. Sign-in for an organization homed in another region answers WRONG_REGION with its sign-in URL there.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  regions: {
    current: 'eu-west-1',
    regions: {
      'eu-west-1': { baseURL: 'https://eu.identity.example.com', label: 'Europe' },
      'us-east-1': { baseURL: 'https://us.identity.example.com', label: 'United States' },
    },
  },
  // ...database, secret, baseURL
});

See Multi-region deployments.

Application callbacks

These callbacks connect authorization to your own data. They run as trusted server code during decisions.

resolveResource

TypeDefault
(reference: ResourceRef) => Promise<ResourceRef & { attributes?: Record<string, unknown>; classification?: ClassificationLabel | null }>none

Looks up an application-owned resource (a type that is not managed) in your trusted storage and returns its tenantId and attributes, so policies can check ownership and attributes. It is required for such types: without it, their checks fail with RESOURCE_RESOLVER_REQUIRED. With clearances on, it may also return a classification label, which can only raise the label IAM holds.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  resolveResource: async ({ type, id }) => {
    const document = await db.documents.find(id); // your own data access
    return { type, id, tenantId: document.tenantId, attributes: { ownerId: document.ownerId } };
  },
  // ...database, secret, baseURL
});

resolveContext

TypeDefault
(principal: AuthenticatedPrincipal) => Promise<Record<string, unknown>>none

Adds trusted, server-derived keys to every policy evaluation, such as the plan a tenant pays for. Plugin context and server-owned keys take precedence over what it returns. Never pass browser input through it.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  resolveContext: async (principal) => ({ plan: await plans.forTenant(principal.tenantId) }),
  // ...database, secret, baseURL
});

plans stands for your own billing lookup.

Access workflows

accessRequests

OptionTypeDefaultDescription
lifetimeMsnumber7 daysHow long a pending access request stays open, one minute to 365 days.
maxDurationSecondsnumber90 days (7776000)The longest temporary grant a request may ask for, 60 seconds to ten years.
iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  accessRequests: { lifetimeMs: 3 * 86_400_000, maxDurationSeconds: 30 * 86_400 },
  // ...database, secret, baseURL
});

See Temporary access.

accessUsage

TypeDefault
boolean | AccessUsageOptionsfalse (off)

Records which actions each identity actually uses, so roleMining.usage and roleMining.rightSize can point out unused bindings and never-used grants. Usage is buffered in memory and written in batches; call iam.flushAccessUsage() before the process exits.

OptionTypeDefaultDescription
flushIntervalMsnumber1 minuteHow often buffered usage is written. Values below one second are raised to one second.
maxBufferednumber10000Buffered (identity, action) pairs that trigger an early write.
iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  accessUsage: { flushIntervalMs: 30_000 },
  // ...database, secret, baseURL
});

See Usage and role mining.

Events, audit, and telemetry

events

OptionTypeDefaultDescription
onEvent(event: AuditEvent) => Promise<void> | voidnoneCalled after commit for every audit event, from the dispatcher worker, at least once.
deliverWebhook(delivery: WebhookDelivery) => Promise<void>built-in HTTPS POSTReplaces the webhook transport. Signature headers are already computed; throw to signal failure.
webhookTimeoutMsnumber10000Timeout of the built-in transport, 1000 to 120000.
allowPrivateNetworksbooleanfalseLets webhook endpoints reach private and reserved addresses such as 10.0.0.0/8 or 169.254.169.254. Loopback over http is allowed only while baseURL is http.

A WebhookDelivery carries id, tenantId, webhookId, url, event, body, and headers.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  events: {
    onEvent: async (event) => {
      await siem.send(event); // your own sink
    },
    webhookTimeoutMs: 5_000,
  },
  // ...database, secret, baseURL
});

See Events and Webhooks.

auditArchive

OptionTypeDefaultDescription
write(batch: AuditArchiveBatch) => Promise<void>requiredStores one verified batch durably. Batches arrive in chain order per tenant; key them by tenantId, fromSequence, and toSequence, and reject (throw) a replacement with different content.
batchSizenumber1000Events per batch, 1 to 10000.
leaseMsnumber10 minutesHow long one run may hold a tenant before another may take over, one minute to one hour.

Archiving runs when you call iam.archiveAudit(). While auditArchive is set, iam.pruneAudit deletes only events already archived. createJsonlAuditArchive from better-iam/server is a file-based sink.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  auditArchive: {
    write: async (batch) => {
      const key = `${batch.tenantId}/${batch.fromSequence}-${batch.toSequence}.json`;
      await bucket.putIfAbsent(key, JSON.stringify(batch)); // your object storage
    },
    batchSize: 5_000,
  },
  // ...database, secret, baseURL
});

See Continuous audit archiving.

observability

OptionTypeDefaultDescription
onSpan(span: IamSpan) => voidnoneReceives one span per operation, authorization query, authentication call, and HTTP request. Exceptions are ignored.
metricsboolean | MetricsOptionsoffKeeps Prometheus-style counters and latency histograms in iam.metrics.

MetricsOptions:

OptionTypeDefaultDescription
bucketsnumber[][0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]Histogram bucket upper bounds in seconds, sorted ascending. A bound that is not positive throws a plain Error (Metrics buckets must be positive numbers).
bearerTokenstringnoneServes GET {basePath}/metrics to requests with Authorization: Bearer <token>. Without it, metrics are programmatic only.
maxSeriesnumber2000Distinct label sets per metric before new ones collapse into (other).
gaugesbooleanfalseAlso reports better_iam_outbox_messages and better_iam_sessions_live, read from storage on each scrape.

An IamSpan has kind, name, tenantId, outcome (ok, denied, or error), code, status, requestId, and durationMs.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  observability: {
    // histogram stands for an instrument from your own metrics library
    onSpan: (span) => histogram.observe({ kind: span.kind, outcome: span.outcome }, span.durationMs),
    metrics: { bearerToken: process.env.METRICS_TOKEN! },
  },
  // ...database, secret, baseURL
});

See Observability.

Extensions and protocols

plugins

TypeDefault
IamPlugin[][]

Plugins add actions, platform resource types, POST endpoints under {basePath}/plugins/{pluginId}/..., operation hooks, policy context, migrations, and purge handlers. Construction checks for unique ids, registered endpoint actions, and unique, well-formed endpoint paths with a validator and a handler. createProjectsPlugin() from better-iam/projects is the reference plugin.

FieldTypeDescription
idstringUnique plugin id.
actionsstring[]Actions the plugin adds to the catalog.
resourceTypesRecord<string, ResourceTypeDefinition>Platform resource types, validated like permissions.resourceTypes.
endpointsPluginEndpoint[]Endpoints, each with method: 'POST', path, action, validate, optional resource, and handler.
hooks{ beforeOperation?, afterOperation? }Run inside an operation's transaction; throwing aborts it.
validateConfig() => voidCalled at construction.
migrate(store: IamStore) => Promise<void>Run by iam.initialize() and better-iam migrate.
resolveContext(principal) => Promise<Record<string, unknown>>Adds trusted keys to the policy context.
afterAudit(event: AuditEvent) => Promise<void>Called for audit events.
purge(store, tenantIds) => Promise<void>Removes plugin-owned records before purged tenants are deleted.
iam.ts
import { betterIam } from 'better-iam';
import { createProjectsPlugin } from 'better-iam/projects';

export const iam = betterIam({
  plugins: [createProjectsPlugin()],
  // ...database, secret, baseURL
});

See Adapters and plugins.

protocols

TypeDefault
ProtocolMount[][]

Raw request handlers consulted before the IAM routes. A ProtocolMount has an optional handle(request: Request): Promise<Response | undefined> and an optional nodeHandler(req, res); returning undefined (or false) passes the request on. Most deployments attach the OAuth, SAML, and SCIM services with iam.useProtocol(service) after construction instead of listing them here.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  protocols: [
    {
      handle: async (request) =>
        new URL(request.url).pathname === '/.well-known/acme-status'
          ? Response.json({ ok: true })
          : undefined,
    },
  ],
  // ...database, secret, baseURL
});

See Protocol mounts.

Temporary credentials

sts

OptionTypeDefaultDescription
maxRoleSessionSecondsnumber3600Ceiling for role session durations, 900 to 43200. Trusts can only lower it.
maxSessionTokenSecondsnumber43200Ceiling for session token durations, 900 to 129600.
maxSessionTokensPerIdentitynumber50Live session tokens per identity, 1 to 1000.
jwtSessionTokenSigningOptionsoffEnables format: 'jwt' tokens and the JWKS route. See sts.jwt.
webIdentityStsWebIdentityOptionsoffAssumeRoleWithWebIdentity. See sts.webIdentity.
iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  sts: { maxRoleSessionSeconds: 7200, maxSessionTokensPerIdentity: 20 },
  // ...database, secret, baseURL
});

See Tokens and temporary credentials.

sts.jwt

OptionTypeDefaultDescription
signingKeysJWK[]required1 to 10 private JWKs: kty: 'OKP', crv: 'Ed25519', alg: 'EdDSA' or kty: 'EC', crv: 'P-256', alg: 'ES256', each with a unique kid (1 to 64 of A-Z a-z 0-9 . _ -), use absent or 'sig', key_ops absent or including 'sign'.
activeKeyIdstringthe first signing keyThe kid new tokens are signed with.
verificationKeysJWK[][]0 to 10 public-only retired keys that still verify but never sign.
issuerstring`${baseURL.origin}${basePath}`The iss claim.
audiencesstring[]the issuerAudiences a token may be issued for; the issuer is always included.
maxLifetimeSecondsnumber3600Longest JWT lifetime, 300 to 43200.

These keys are separate from secret and are never shared with the OAuth provider. The public keys are served at GET {basePath}/.well-known/jwks.json.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  sts: {
    jwt: {
      signingKeys: [JSON.parse(process.env.SESSION_JWT_KEY!)], // { kty: 'OKP', crv: 'Ed25519', alg: 'EdDSA', kid, x, d }
      audiences: ['https://api.example.com'],
      maxLifetimeSeconds: 900,
    },
  },
  // ...database, secret, baseURL
});

sts.webIdentity

OptionTypeDefaultDescription
enabledbooleanfalseTurns web-identity federation on.
allowedIssuersstring[]any https issuerPins the issuers any tenant may register as an OIDC provider, at most 100.
jwksCacheSecondsnumber600How long fetched provider keys are cached, 60 to 3600.
fetchTimeoutMsnumber5000Timeout of each discovery or JWKS fetch, 500 to 10000.
maxJwksBytesnumber65536Largest accepted discovery or JWKS response, 1024 to 1048576 bytes.
maxExchangesPerWindownumber600Exchanges per trust per rate-limit window, 1 to 100000.
maxSessionsPerTrustnumber1000Live role sessions per web-identity trust, 1 to 100000.
allowPrivateNetworksbooleanfalseLets discovery and JWKS fetches reach private addresses. Development and tests only.
allowInsecureLocalhostbooleanfalseAccepts http:// issuers and key URLs on loopback hosts. Development and tests only.
fetchJson(url: URL) => Promise<unknown>built-in guarded fetchReplaces the fetch, which refuses private addresses, redirects, and oversized or non-JSON responses.
iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  sts: {
    webIdentity: {
      enabled: true,
      allowedIssuers: ['https://token.actions.githubusercontent.com'],
    },
  },
  // ...database, secret, baseURL
});

Optional modules

Each option in this section turns on, or tunes, one feature module. inference, a2a, ssh, verifiableCredentials, and clearances are off until set; while off, their methods answer FEATURE_DISABLED. billing, vault, and signals are always available; their options tune or extend them.

inference

TypeDefault
boolean | InferenceOptionsoff

Inference access control for AI models: the model resource type and the inference:invoke action, the inference API group (providers with sealed keys, models, budgets, usage), and iam.inference (checks, metering, and the gateway). true enables it with defaults.

OptionTypeDefaultDescription
allowCustomBaseUrlsbooleanfalseLets organizations below the root point a provider at a custom base URL. Otherwise only a root administrator may.
usageRetentionDaysnumber90How long per-request usage records are kept, 1 to 3650 days.
allowInsecureLocalhostbooleanfalseLets base URLs use http on loopback hosts. Development and tests only.
allowPrivateNetworksbooleanfalseLets organization-chosen base URLs reach private and reserved addresses. Base URLs of the root tenant's providers are never restricted.
iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  inference: { usageRetentionDays: 30 },
  // ...database, secret, baseURL
});

See Model access and budgets.

a2a

TypeDefault
A2aOptionsoff

Keys for IAM-attested Agent2Agent cards (agents.signCard) and delegation tokens; their public half is published by iam.a2a.jwks(). Delegation tokens need this option.

OptionTypeDefaultDescription
signingKeysJWK[]required1 to 10 private Ed25519 (EdDSA) or P-256 (ES256) JWKs, each with a unique kid. Use keys of their own, not the sts.jwt keys.
activeKeyIdstringthe first signing keyThe kid cards are signed with.
verificationKeysJWK[][]0 to 10 public-only retired keys, still published so cards they signed keep verifying.
jwksUrlstringnoneWhere you publish iam.a2a.jwks(); carried as jku in every card signature.
cardLifetimeSecondsnumber3600How long an attestation is valid, 300 to 604800.
issuerstring`${baseURL.origin}${basePath}`The attestation's issuer.
iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  a2a: {
    signingKeys: [JSON.parse(process.env.A2A_CARD_KEY!)],
    jwksUrl: 'https://identity.example.com/.well-known/a2a-jwks.json',
  },
  // ...database, secret, baseURL
});

See Agent-to-agent.

billing

TypeDefault
BillingOptionsdefaults below; billing is always available
OptionTypeDefaultDescription
currencystring'USD'ISO 4217 code every amount is stated in.
timeZonestring'UTC'IANA time zone billing months and days follow.
usageRetentionDaysnumber400Days raw usage events are kept after their period ends, 1 to 3650. Daily roll-ups and statements stay.
teamAttribution'split' | 'primary' | 'full''split'How a person's cost counts toward their teams: split evenly, all to the oldest team, or in full to each.
paymentTermsDaysnumber30Days after issue a statement is due when the billing profile sets no terms, 0 to 365.
statementPrefixstring'INV'Statement number prefix (1 to 16 uppercase letters, digits, or hyphens); numbers read {prefix}-{YYYYMM}-{sequence}.
issuerInvoiceIssuerthe name Better IAM onlyWho issues invoices: name (required), address, taxId, email, url, paymentInstructions.
paymentReminderDaysnumber[][-3, 0, 7, 14]When billing.sendPaymentReminders emails billing contacts, in days relative to the due date (at most 10 values, -60 to 365). [] sends none.
autoFinalizebooleantrueWhether closePeriod finalizes monthly invoices at once. false keeps them as drafts for review.
iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  billing: {
    currency: 'EUR',
    timeZone: 'Europe/Berlin',
    issuer: { name: 'Acme GmbH', email: 'billing@acme.example' },
    autoFinalize: false,
  },
  // ...database, secret, baseURL
});

See Billing and spend.

ssh

TypeDefault
boolean | SshOptionsoff

The SSH certificate authority: short-lived OpenSSH user certificates for the hosts and logins policies allow, host certificates, and revocation lists (ssh API group, iam.ssh). true enables it with defaults.

OptionTypeDefaultDescription
maxUserCertificateMsnumber24 hoursThe longest user certificate any tenant may allow, 5 minutes to 7 days.
hostCertificateMsnumber90 daysHost certificate lifetime, 1 day to 1 year.
clockSkewMsnumber5 minutesHow far valid after is backdated, 0 to 1 hour.
maxHostsPerCertificatenumber64The most hosts one user certificate may name, 1 to 256.
recordRetentionDaysnumber90How long certificate records stay after they expire, 1 to 3650.
joinTokenMsnumber24 hoursHow long a host join token stays valid, 5 minutes to 30 days.
issuanceLimitnumber60Certificates one identity may request per rate-limit window, 1 to 100000.
iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  ssh: { maxUserCertificateMs: 8 * 60 * 60_000 },
  // ...database, secret, baseURL
});

See SSH access.

verifiableCredentials

TypeDefault
boolean | VerifiableCredentialOptionsoff

Every tenant issues SD-JWT verifiable credentials to wallets over OpenID4VCI and verifies presentations (verifiableCredentials API group, the {basePath}/vc/{tenantId} issuer endpoints, iam.verifiableCredentials). true enables it with defaults.

OptionTypeDefaultDescription
maxLifetimeMsnumber365 daysThe longest credential lifetime any type may set, 5 minutes to 5 years.
statusListSizenumber65536Entries per status list, 1024 to 1048576 and a multiple of 1024. A list is used up to half full.
offerLifetimeMsnumber10 minutesHow long a wallet offer can be redeemed, 1 minute to 24 hours.
statusListLifetimeMsnumber24 hoursHow long a status list token is valid, 10 minutes to 30 days.
statusListTtlSecondsnumber300How long verifiers may cache a status list (ttl), 10 to 86400.
recordRetentionDaysnumber30How long credential records stay after the credential expired, 1 to 3650.
iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  verifiableCredentials: { maxLifetimeMs: 90 * 86_400_000 },
  // ...database, secret, baseURL
});

See Verifiable credentials.

clearances

TypeDefault
ClearanceOptionsoff

Security clearances and mandatory access control: tenants define a classification scheme, officers adjudicate clearances and read people into compartments, and resources carry classification labels that every party of a decision must dominate (clearances API group, iam.clearances). Its presence enables the module, and it must be an object: clearances: true fails with clearances must be an object, and unknown keys fail too.

OptionTypeDefaultDescription
appliesToRootbooleantrueWhether root administrators are bound by classification labels too. Root keeps its override for everything else.
iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  clearances: {},
  // ...database, secret, baseURL
});

See Security clearances.

vault

TypeDefault
VaultOptionsdefaults below; the vault is always available
OptionTypeDefaultDescription
rotatorsRecord<string, VaultRotator>{}Rotators by name (lowercase identifiers), each with rotate(input). A secret's rotation.rotator names one.
enginesRecord<string, VaultEngine>{}Dynamic secret engines by name, each with issue(input) and optional revoke and renew.
maxValueBytesnumber65536Largest secret value in UTF-8 bytes, 16 to 1048576.
maxSecretsPerTenantnumber1000Secrets per tenant, pending deletions included, 1 to 100000.
accessRetentionDaysnumber90How long per-secret access records stay, 1 to 3650.
callTimeoutMsnumber30000How long a rotator or engine call may take, 1000 to 300000.

A rotator receives { tenantId, name, version, value, fields?, previous?, previousFields?, tags } and must be idempotent: a failed rotation is retried with the same value.

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  vault: {
    rotators: {
      postgres: {
        rotate: async ({ value, tags }) => {
          await databaseAdmin.setPassword(tags.role, value); // your own code that applies the new value
        },
      },
    },
  },
  // ...database, secret, baseURL
});

See Secrets vault.

signals

OptionTypeDefaultDescription
allowPrivateNetworksbooleanfalseLets key, discovery, and poll requests reach private and reserved addresses. Development and tests only.
allowInsecureLocalhostbooleanfalseAccepts http:// issuers, key URLs, and poll endpoints on loopback hosts. Development and tests only.
pushPathstring`${basePath}/signals/push`Where transmitters push: {pushPath}/{sourceId}. An absolute path of at most 256 characters, not ending in /.

These settings configure the Shared Signals receiver (signals API group, iam.signals).

iam.ts
import { betterIam } from 'better-iam';

export const iam = betterIam({
  signals: { pushPath: '/ssf/push' },
  // ...database, secret, baseURL
});

See Shared Signals receiver.

Errors

CodeWhen
INVALID_CONFIGbetterIam() was given a missing or out-of-range option. The message names the field.
FEATURE_DISABLEDA method of a module whose option is off was called (403).
RESOURCE_RESOLVER_REQUIREDA check named an application-owned resource type and resolveResource is not configured.
UNTRUSTED_ORIGINA browser request came from an origin not in trustedOrigins.

Startup validation cannot judge everything, such as a placeholder-like secret or a missing email transport. better-iam doctor checks those; see Doctor.

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page