BetterIAM

Error codes

What each of the 144 Better IAM error codes means, why it happens, and how to handle it, grouped by HTTP status.

Every failure in Better IAM is an IamError with three fields: a stable code such as ACCESS_DENIED, a message written for people, and an HTTP status. Codes are part of the public contract and do not change between releases. Messages may be reworded at any time and can contain names from your data, so show them to people but never parse them: branch on code, never on message.

Over HTTP a refused call answers with that status and the body { "error": { "code": "ACCESS_DENIED", "message": "Access denied" } }. A RATE_LIMITED answer also carries retryAfterMs in the body and a Retry-After header in seconds. An exception that is not an IamError becomes 500 INTERNAL_ERROR with a generic message, so internals never leak. The browser client rethrows the envelope as IamClientError with the same code and status, plus retryAfterMs and, when you create the client with requestId, the requestId it sent as X-Request-Id, which you can quote in support tickets and find on the server's http spans. The CLI prints CODE: message to standard error and exits with status 1.

Each code below says what went wrong, why it happens, and what the caller should do; codes are grouped by HTTP status. On the server, catch IamError and compare its code:

import { IamError } from 'better-iam';

try {
  await iam.require({ headers, tenantId, action: 'documents:write', resource: { type: 'document', id } });
} catch (error) {
  if (error instanceof IamError && error.code === 'ACCESS_DENIED') return forbidden();
  if (error instanceof IamError && error.code === 'RATE_LIMITED') return retryLater(error);
  throw error;
}

Handling errors in a UI

Map codes to what the person should do next, not to messages. A few groups cover most screens: sign in again (UNAUTHENTICATED, SESSION_NETWORK_MISMATCH), step up (MFA_REQUIRED, RECENT_AUTH_REQUIRED), fix the form (INVALID_INPUT, INVALID_CREDENTIALS, WEAK_PASSWORD, shown next to the field), wait (RATE_LIMITED), not allowed (ACCESS_DENIED, where you can offer to request access), and reload because something changed (CONFLICT, VERSION_CONFLICT, INVALID_TRANSITION). Show anything else as a generic failure with the request ID. The Next.js, NestJS, SvelteKit, Nuxt, and Node middleware integrations already treat UNAUTHENTICATED, MFA_REQUIRED, EMAIL_UNVERIFIED, TENANT_INACTIVE, and TENANT_UNAVAILABLE as a signed-out request (Next.js also the network refusals SESSION_NETWORK_MISMATCH, IP_BLOCKED, and IP_NOT_ALLOWED).

import { IamClientError } from 'better-iam/client';

type NextStep =
  | { kind: 'sign-in' }
  | { kind: 'step-up'; code: string }
  | { kind: 'wait'; ms: number }
  | { kind: 'message'; text: string };

/** Decides what the UI does after a failed call. */
export function nextStep(error: unknown): NextStep {
  if (!(error instanceof IamClientError)) throw error; // a network failure or an aborted call
  switch (error.code) {
    case 'UNAUTHENTICATED':
    case 'SESSION_NETWORK_MISMATCH':
      return { kind: 'sign-in' };
    case 'MFA_REQUIRED':
    case 'RECENT_AUTH_REQUIRED':
      return { kind: 'step-up', code: error.code };
    case 'RATE_LIMITED':
      return { kind: 'wait', ms: error.retryAfterMs ?? 60_000 };
    case 'ACCESS_DENIED':
      return { kind: 'message', text: 'You do not have permission to do this.' };
    case 'CONFLICT':
    case 'VERSION_CONFLICT':
      return { kind: 'message', text: 'This changed in the meantime. Reload and try again.' };
    default:
      // requestId is set when the client was created with { requestId: true }.
      return { kind: 'message', text: `Something went wrong (reference ${error.requestId ?? 'none'}).` };
  }
}

INVALID_ARGUMENT

A better-iam command received a flag it does not accept, a missing value, or a value out of range.

The message names the problem, for example a required --tenant or --output, a flag used with the wrong command, or a number outside its allowed range; the command stops before it changes anything.

How to fix: correct the flags using the command's usage line in the CLI reference.

INVALID_COMMAND

The CLI does not know the command you typed.

How to fix: run better-iam help for the list of commands, or check the CLI reference.

400 Bad request

The request itself is the problem: an input failed validation, or the operation is not allowed in the current state of the record. Fix the input or the order of operations; retrying the same request will fail the same way.

CodeMeaning
AUDIT_ARCHIVE_FAILEDThe audit-archive command could not archive the audit events of one or more tenants.
AUDIT_ARCHIVE_INVALIDThe audit-verify-archive command found that a tenant's archived audit chain does not verify.
AUDIT_CHAIN_BROKENThe audit-verify command found that a tenant's audit chain does not verify.
BREACHED_PASSWORDThe new password appears in a known data breach.
CONFIG_DRIFTconfig-plan --fail-on-drift found differences between the tenant and the configuration file.
CONFIG_EXISTSbetter-iam init found an existing configuration file and left it untouched.
CONFIG_WARNINGS… unresolved reference(s)
CREDENTIAL_CHAINING_DISABLEDA session token cannot be minted from a credential that is itself temporary.
DELIVERY_REQUIREDThe operation sends email, but the deployment has no email delivery callback.
DOCTOR_FINDINGSdoctor --strict found problems more serious than informational notes.
DOMAIN_NOT_ALLOWEDThe domain belongs to a shared mailbox provider, so no organization can claim it.
FINDINGSanalyze --fail-on found access findings at or above the chosen severity.
HOSTNAME_NOT_ALLOWEDThe hostname belongs to the deployment itself, so no organization can claim it.
INVALID_ACTIONThe action name is not in the catalog, or a tenant-defined action is malformed.
INVALID_CONFIGThe server, a storage adapter, or the CLI was given an invalid configuration.
INVALID_CREDENTIALThe credential id passed to a credentials method does not refer to an API key.
INVALID_FILTERA storage query was given an invalid filter, ordering, or pagination.
INVALID_POLICYA policy document or access-package rule does not follow the policy grammar.
INVALID_RECORDA record written to storage is not plain JSON or breaks a storage limit.
INVALID_REQUESTA policy evaluation input or an HTTP request target could not be understood.
INVALID_RESOURCE_TYPEThe resource type is unknown, reserved, or cannot be used this way.
INVALID_SEALED_VALUEA stored secret could not be decrypted with the configured secrets.
INVALID_SPONSORThe AI agent's sponsor is missing or is not an active person of the agent's tenant.
INVALID_TICKETThe inference ticket given to inference.record is unknown, already used, expired, or its caller no longer exists.
INVARIANTS_BROKENcheck-invariants --fail-on-broken found invariants that are broken or cannot be evaluated.
INVITATION_INVALIDThe invitation link is invalid, expired, already used, or revoked.
LINKING_DISABLEDAccount linking is turned off on this deployment.
MAX_DEPTHCreating or moving this tenant would make the hierarchy deeper than allowed.
MISSING_ENVA CLI command needs an environment variable that is not set.
NOT_INITIALIZEDRoot recovery was attempted before the platform was bootstrapped.
OAUTH_CALLBACKThe OAuth sign-in callback arrived at a URL that does not match the connection's redirect URI.
OAUTH_STATEThe OAuth sign-in could not be matched to the browser that started it.
PASSWORD_REUSEDThe new password matches one of the person's recent passwords.
PROTECTED_IDENTITYA root administrator cannot be linked during organization onboarding.
RECONCILE_ATTENTIONreconcile --fail-on-attention found access-package rules that need attention.
RESOURCE_RESOLVER_REQUIREDAn authorization check names an application resource type, but no resolveResource callback is configured.
ROLE_CHAINING_DISABLEDA role cannot be assumed from a session that is itself an assumed role.
ROLLBACKA deliberate signal inside the storage conformance suite that discards a test transaction.
SNAPSHOT_INVALIDThe file given to store-import is not a valid Better IAM snapshot.
SNAPSHOT_TRUNCATEDThe snapshot ended early, or its record counts do not match its trailer.
UNREADABLE_SECRETSrotate-secrets found stored values that none of the configured secrets can open.
VERIFIED_EMAIL_REQUIREDA first sign-in through an external provider did not come with a verified email address.
WEAK_PASSWORDThe new password does not meet the password rules.
WEAK_TRUST_CONDITIONSA web-identity trust must name exactly which workload may use it, and these conditions do not.

AUDIT_ARCHIVE_FAILED

The audit-archive command could not archive the audit events of one or more tenants.

The command prints its full result first and names each failed tenant with the error code it hit. Tenants that succeeded are archived, and every tenant keeps its own archive position.

How to fix: resolve the listed errors (often the archive sink's storage or permissions) and run the command again; it continues from each tenant's position (continuous audit archiving).

HTTP 400 · thrown by @better-iam/cli

Example messages:

  • … tenant(s) could not be archived: ${result.failed.map((failure) =>

AUDIT_ARCHIVE_INVALID

The audit-verify-archive command found that a tenant's archived audit chain does not verify.

The command checks the archive files alone, without the database: every sequence must appear once, hashes are recomputed, and links must be intact. It also fails when two archived copies of the same sequence disagree, and prints the result, including the failure reason and sequence, before it exits.

How to fix: treat it as a possible tampering or data-loss incident, and compare the failing range with the database (audit-verify) and your backups (audit chain).

HTTP 400 · thrown by @better-iam/cli

AUDIT_CHAIN_BROKEN

The audit-verify command found that a tenant's audit chain does not verify.

Each audit event carries a hash of the one before it, so a changed, removed, or reordered event breaks the chain. The command prints the verification result, including where it failed, before it exits.

How to fix: treat it as a possible tampering or data-loss incident: find the failing sequence in the output, compare it with your archive or backups, and review who can write to the database (audit chain).

HTTP 400 · thrown by @better-iam/cli

Example messages:

  • The audit chain does not verify

BREACHED_PASSWORD

The new password appears in a known data breach.

It is raised wherever a password is set (sign-up, password change, password reset, and other flows that set one) when the deployment screens passwords with authentication.passwordPolicy.isBreached, for example the built-in pwnedPasswords() client.

How to fix: ask the person to choose a different password, and show the message next to the password field (password screening).

HTTP 400 · thrown by @better-iam/auth

Example messages:

  • This password appears in a known data breach; choose a different one

CONFIG_DRIFT

config-plan --fail-on-drift found differences between the tenant and the configuration file.

The command prints the full plan first; the count covers the items it would create, update, or delete. The flag exists so a CI job fails when someone changed production outside the reviewed file.

How to fix: review the plan, then apply the file with config-apply or update the file to the intended state (configuration as code).

HTTP 400 · thrown by @better-iam/cli

Example messages:

  • The tenant differs from the configuration in … item(s)

CONFIG_EXISTS

better-iam init found an existing configuration file and left it untouched.

How to fix: edit the existing file, or pass --config with a new path to generate a fresh one.

HTTP 400 · thrown by @better-iam/cli

Example messages:

  • Configuration already exists; it was not overwritten

CONFIG_WARNINGS

HTTP 400 · thrown by @better-iam/cli

Example messages:

  • … unresolved reference(s)

CREDENTIAL_CHAINING_DISABLED

A session token cannot be minted from a credential that is itself temporary.

sts.getSessionToken accepts only a signed-in session or an API key as its source. Role sessions and other session tokens are refused, so a temporary credential can never be renewed or extended by deriving a new one from it.

How to fix: call sts.getSessionToken with the person's own session or the service account's API key (sts).

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Temporary credentials cannot mint session tokens

DELIVERY_REQUIRED

The operation sends email, but the deployment has no email delivery callback.

Organization and member invitations (tenants.create, identities.invite, and their resend calls), certification reminders (certifications.remind), the access digest, and expiry reminders enqueue email and refuse to run without authentication.sendEmail.

How to fix: configure sendEmail in the server options (configuration); in tests, a callback that records messages is enough.

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Reminders require an email delivery callback
  • Member invitations require an email delivery callback
  • Organization invitations require an email delivery callback
  • The access digest requires an email delivery callback
  • Expiry reminders require an email delivery callback

DOCTOR_FINDINGS

doctor --strict found problems more serious than informational notes.

doctor checks the schema, durability settings, secrets, delivery transports, and scheduled jobs, and prints every finding; with --strict, any finding above info severity fails the run.

How to fix: fix each check named in the output, or run without --strict while you work through them (doctor).

HTTP 400 · thrown by @better-iam/cli

Example messages:

  • … finding(s): …

DOMAIN_NOT_ALLOWED

The domain belongs to a shared mailbox provider, so no organization can claim it.

domains.add refuses consumer email domains (a default block list, replaced by the domains.blockedDomains option), because verifying one would route everyone with such an address to a single organization.

How to fix: claim the organization's own domain instead.

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Shared mailbox providers cannot be claimed by an organization

FINDINGS

analyze --fail-on found access findings at or above the chosen severity.

The command prints the report first; the count covers unsuppressed findings at the --fail-on level or higher, so a CI job fails when new risk appears.

How to fix: fix the findings, or record accepted ones with analysis.suppress so they stop failing the run (scan for risky configuration).

HTTP 400 · thrown by @better-iam/cli

Example messages:

  • … finding(s) at or above … severity

HOSTNAME_NOT_ALLOWED

The hostname belongs to the deployment itself, so no organization can claim it.

hostnames.add refuses the deployment's base URL host, its trusted origins, and every name in its organization subdomain space (anything matching or under a hosts.patterns template), because those addresses already route to the deployment or to other organizations.

How to fix: claim a hostname on the organization's own domain, such as login.acme.com.

HTTP 400 · thrown by @better-iam/server

Example messages:

  • This hostname belongs to the deployment itself and cannot be claimed

INVALID_ACTION

The action name is not in the catalog, or a tenant-defined action is malformed.

Policy documents, listAccessible, access paths, invariants, policies.whoCan, and impact previews accept only actions the catalog knows (wildcards excepted). actions.register needs the {resourceType}:{verb} form under a tenant-defined resource type that is registered first.

How to fix: check the spelling, then declare the action in permissions.actions or register it (catalog).

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Unknown action …
  • Tenant actions must be {resourceType}:{verb} under a tenant-defined resource type
  • Register resource type … before its actions

INVALID_CONFIG

The server, a storage adapter, or the CLI was given an invalid configuration.

It surfaces at startup: when you call betterIam(), create an adapter, or when a CLI command loads your configuration file. Examples are a missing database, a plain-HTTP baseURL outside localhost, an unsupported database URL, or email features enabled without sendEmail; the browser client raises it too (with status 0) for a bad baseURL or basePath.

How to fix: correct the option the message names (configuration).

HTTP 400 · thrown by @better-iam/adapter-libsql, @better-iam/adapter-postgres, @better-iam/adapter-sqlite, @better-iam/auth, @better-iam/cli, @better-iam/server

Example messages:

  • libSQL requires a url: :memory:, file:, libsql:, https:, or wss:
  • Unsupported libSQL URL scheme
  • busyTimeoutMs must be between 0 and 60000
  • A PostgreSQL connection string is required
  • Invalid PostgreSQL pool size or lock timeout
  • SQLite requires a filename or :memory:; URI filenames are unsupported

INVALID_CREDENTIAL

The credential id passed to a credentials method does not refer to an API key.

credentials.get, credentials.update, credentials.revoke, and credentials.rotate manage service-account API keys only, so the id of a user session or temporary credential is refused.

How to fix: pass an API key id from credentials.list; end user sessions with identities.revokeSessions instead.

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Not an API key credential
  • Only API keys can rotate

INVALID_FILTER

A storage query was given an invalid filter, ordering, or pagination.

It comes from IamStore.find and findOrdered: the filter must be a plain JSON object, limit and offset nonnegative safe integers, the after cursor an id, and ordering needs a top-level field, asc or desc, and finite bounds. You meet it when a plugin or adapter works with the store directly.

How to fix: correct the query arguments (adapter contract).

HTTP 400 · thrown by @better-iam/core

Example messages:

  • Field names in SQL conditions must be identifiers
  • SQLite adapters do not evaluate JSON conditions
  • Filter must be a JSON object
  • Pagination must use nonnegative safe integers
  • The pagination cursor must be an id
  • Order options are required

INVALID_POLICY

A policy document or access-package rule does not follow the policy grammar.

Policies, boundaries, ceilings, API key session policies, and package rules are validated when saved; the message says what is wrong, and for package rules it starts with the path of the failing clause.

How to fix: correct the document; analysis.lintPolicy checks one before you save it (policies).

HTTP 400 · thrown by @better-iam/core, @better-iam/server

Example messages:

  • …: …

INVALID_RECORD

A record written to storage is not plain JSON or breaks a storage limit.

A stored record must be a JSON object whose collection name, id, tenantId, and uniqueKey are nonempty strings of at most 512 UTF-8 bytes, nested at most 64 levels, without symbols, cycles, or unpaired surrogates, and under one million characters. You meet it when a plugin or your own code writes to iam.store.

How to fix: store only plain JSON values within these limits (adapter contract).

HTTP 400 · thrown by @better-iam/core

Example messages:

  • … must be a nonempty string of at most 512 UTF-8 bytes
  • … cannot contain unpaired surrogates
  • Record nesting exceeds 64 levels
  • Records must contain JSON data only
  • Circular records are not supported
  • Records cannot contain symbols

INVALID_REQUEST

A policy evaluation input or an HTTP request target could not be understood.

evaluatePolicy from @better-iam/core needs an action, a resource, and a grants array (with optional boundaries and context), and the Node.js handler refuses a request URL it cannot parse.

How to fix: pass a complete evaluation input, or fix the client or proxy that produced the URL.

HTTP 400 · thrown by @better-iam/core, @better-iam/server

Example messages:

  • Invalid policy evaluation request
  • Invalid request target

INVALID_RESOURCE_TYPE

The resource type is unknown, reserved, or cannot be used this way.

resourceTypes.register refuses a name the platform already defines and a parent that is not an existing managed type. Registering resources, creating relationships, and policy documents need a known type, and only managed types hold registered resources; other types are resolved by your resolveResource callback.

How to fix: declare or register the type first, and check whether it is managed (resources and catalog).

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Resource type name is reserved or already defined by the platform
  • Parent must be an existing managed resource type
  • Unknown resource type
  • This resource type is resolved by the application, not registered with IAM
  • Unknown resource type …

INVALID_SEALED_VALUE

A stored secret could not be decrypted with the configured secrets.

Better IAM seals sensitive values at rest (webhook signing secrets, queued delivery payloads, downstream SCIM tokens, and others) with the deployment secret. When that secret changed and the old one is not in previousSecrets, or the stored value was damaged, the value cannot be opened.

How to fix: add the secret that sealed the value to previousSecrets, then run rotate-secrets to re-seal everything under the current secret (secrets).

HTTP 400 · thrown by @better-iam/auth, @better-iam/scim, @better-iam/server

Example messages:

  • Invalid sealed value
  • Outbox payload is not sealed
  • The stored downstream token cannot be opened.
  • Webhook payload is incomplete

INVALID_SPONSOR

The AI agent's sponsor is missing or is not an active person of the agent's tenant.

Every agent needs a sponsor, an active and unexpired person of the same tenant who answers for it. agents.create makes the caller the sponsor only when the caller is a person in their own session, so an API key or a temporary credential must name one with sponsorId. agents.create and agents.update refuse a sponsor who is a service account or another agent, disabled, deleted, expired, or in another tenant.

How to fix: pass the sponsorId of an active member of the agent's tenant (agents.create).

HTTP 400 · thrown by @better-iam/server

Example messages:

  • An agent sponsor must be an active person of the same tenant
  • Name the person accountable for the agent (sponsorId)

INVALID_TICKET

The inference ticket given to inference.record is unknown, already used, expired, or its caller no longer exists.

An allowed inference.check returns a single-use ticket that is valid for one hour and names the caller, their session, and the model. An external gateway redeems it once with the call's token counts; a ticket of another tenant is refused too.

How to fix: meter each call once, within the hour, with the ticket its own check returned; when a ticket is lost, the call cannot be metered through record, so check again before the next call.

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Unknown, used or expired inference ticket

INVARIANTS_BROKEN

check-invariants --fail-on-broken found invariants that are broken or cannot be evaluated.

The command prints the full run first, then fails so a CI job or scheduler notices.

How to fix: review the violations and errors in the output, then correct the access or the invariant (access invariants).

HTTP 400 · thrown by @better-iam/cli

Example messages:

  • … broken and … unevaluable invariant(s)

INVITATION_INVALID

The invitation link is invalid, expired, already used, or revoked.

tenants.acceptInvitation and identities.acceptInvitation refuse a token that matches no open invitation, an organization that is no longer pending, and an invitation whose inviter's grant authority was revoked after it was sent.

How to fix: ask an administrator to send a new invitation with tenants.resendInvitation or identities.resendInvitation.

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Invitation is invalid
  • Invitation authority revoked

LINKING_DISABLED

Account linking is turned off on this deployment.

links.create, and accepting an organization invitation with a link to an existing account, need onboarding: { mode: 'linked' } in the server options.

How to fix: enable linked onboarding in your configuration, or accept the invitation without linking.

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Linked onboarding is disabled
  • Account linking disabled

MAX_DEPTH

Creating or moving this tenant would make the hierarchy deeper than allowed.

tenants.create and tenants.reparent enforce hierarchy.maxDepth, eight levels by default.

How to fix: attach the tenant higher in the tree, or raise maxDepth in your configuration if deeper nesting is intended.

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Maximum tenant depth reached

MISSING_ENV

A CLI command needs an environment variable that is not set.

Commands that act as a member (config-export, config-plan, config-apply, analyze, report, check-invariants, mine-roles) read a session token or API key from BETTER_IAM_TOKEN; bootstrap and recover-root read BETTER_IAM_ROOT_EMAIL and BETTER_IAM_ROOT_PASSWORD. Secrets never go on the command line.

How to fix: export the variable the message names and run the command again.

HTTP 400 · thrown by @better-iam/cli

Example messages:

  • Set BETTER_IAM_DATABASE_URL
  • Set BETTER_IAM_SECRET

NOT_INITIALIZED

Root recovery was attempted before the platform was bootstrapped.

recoverRoot and better-iam recover-root add a root administrator to the existing root tenant, so one must exist.

How to fix: run bootstrap first (recovering root access).

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Bootstrap first

OAUTH_CALLBACK

The OAuth sign-in callback arrived at a URL that does not match the connection's redirect URI.

The callback's origin and path must equal the connection's redirectUri, so a response meant for another route is never accepted.

How to fix: make the redirect URI registered with the provider, the connection's redirectUri, and the route that handles the callback identical (OAuth sign-in).

HTTP 400 · thrown by @better-iam/oauth

Example messages:

  • Unexpected callback URL.

OAUTH_STATE

The OAuth sign-in could not be matched to the browser that started it.

The callback needs the state parameter and the browser-binding cookie set when the sign-in began. State that is missing, expired (after ten minutes), already used, or from another browser is refused, which blocks login CSRF and replayed callbacks.

How to fix: start the sign-in again in the same browser, and check that the binding cookie reaches the callback route.

HTTP 400 · thrown by @better-iam/oauth

Example messages:

  • Missing login state.
  • Invalid, expired, or replayed login state.

PASSWORD_REUSED

The new password matches one of the person's recent passwords.

A tenant's authPolicy.passwordHistory refuses the last that many passwords when a password is changed or reset.

How to fix: ask the person to choose a password they have not used recently (password rules).

HTTP 400 · thrown by @better-iam/auth

Example messages:

  • Password must differ from your last … password…

PROTECTED_IDENTITY

A root administrator cannot be linked during organization onboarding.

Accepting an organization invitation with linked onboarding refuses a link credential that belongs to a root administrator, so platform identities stay separate from customer organizations.

How to fix: accept the invitation without linking, or link an ordinary account.

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Root identities cannot link

RECONCILE_ATTENTION

reconcile --fail-on-attention found access-package rules that need attention.

The count covers package rules that failed or were suspended, runs stopped by the safety brake until someone confirms them, and tenants that could not be processed; the full result is printed first.

How to fix: review the listed packages, fix their rules, and confirm braked changes with reconcile --tenant ID --package ID --confirm (automatic assignment).

HTTP 400 · thrown by @better-iam/cli

Example messages:

  • … package rule problem(s) need attention

RESOURCE_RESOLVER_REQUIRED

An authorization check names an application resource type, but no resolveResource callback is configured.

Managed resource types are registered with IAM and read from its storage; for every other type Better IAM needs your resolveResource option to load the resource and its attributes.

How to fix: configure resolveResource, or declare the type as managed and register its resources with resources.register (resources and catalog).

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Configure resolveResource for application resources

ROLE_CHAINING_DISABLED

A role cannot be assumed from a session that is itself an assumed role.

roles.assume refuses role sessions as the source, so temporary access can never be extended by chaining one role into another. A session token from sts.getSessionToken is accepted as a source, because it is the same identity with the same or fewer grants.

How to fix: assume the role from the original user session or API key (assumed roles).

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Role chaining is disabled

ROLLBACK

A deliberate signal inside the storage conformance suite that discards a test transaction.

The suite throws it to prove that an adapter rolls back uncommitted writes; the API never returns it.

How to fix: nothing, unless a conformance check around it fails, which means your adapter did not roll the transaction back as required (conformance suite).

HTTP 400 · thrown by @better-iam/core

Example messages:

  • discard

SNAPSHOT_INVALID

The file given to store-import is not a valid Better IAM snapshot.

Every line must be a JSON object: a header naming the snapshot format and version, one record per line, and a trailer with counts. An unsupported version or content after the trailer is refused, and nothing is imported.

How to fix: import a file written by store-export, unmodified (snapshots).

HTTP 400 · thrown by @better-iam/core

Example messages:

  • Content after the trailer on line …
  • Line … is not JSON
  • Line … is not an object
  • Not a … version … snapshot
  • The trailer on line … has no counts
  • Line … is not a record entry

SNAPSHOT_TRUNCATED

The snapshot ended early, or its record counts do not match its trailer.

store-import checks the trailer that store-export writes last; a missing trailer or a count mismatch means the file was cut off or edited, so nothing is imported.

How to fix: export the snapshot again and copy it completely (snapshots).

HTTP 400 · thrown by @better-iam/core

Example messages:

  • The snapshot has no trailer; nothing was imported
  • Record counts do not match the snapshot trailer; nothing was imported

UNREADABLE_SECRETS

rotate-secrets found stored values that none of the configured secrets can open.

The command re-seals values encrypted with previousSecrets under the current secret and reports any it cannot open, which means the secret that sealed them is missing from the configuration.

How to fix: add that secret to previousSecrets and run the command again; keep old secrets until it passes (secrets).

HTTP 400 · thrown by @better-iam/cli

Example messages:

  • … stored value(s) open with no configured secret; add the secret that sealed them to previousSecrets

VERIFIED_EMAIL_REQUIRED

A first sign-in through an external provider did not come with a verified email address.

Enrolling a new account from an OAuth, OpenID Connect, or SAML sign-in needs an email the provider asserts as verified, because the account is created with that email already verified.

How to fix: have the person verify their email at the provider, or configure the connection to release a verified email; a person with an existing account can link the provider to it instead (first sign-in).

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Federation enrollment needs a verified email

WEAK_PASSWORD

The new password does not meet the password rules.

Every password needs at least 12 characters. A tenant can add a longer minimum, required character classes, and a ban on the person's name or email; the built-in screen refuses common and keyboard-pattern passwords; and a custom passwordPolicy.check can add its own rule and message.

How to fix: show the message next to the password field and let the person choose a stronger password (password rules).

HTTP 400 · thrown by @better-iam/auth

Example messages:

  • Password must contain at least … characters in this organization
  • Password must mix at least … of: lowercase, uppercase, digits, symbols
  • Password must not contain your name or email address
  • Password is too common or predictable
  • Password must contain at least 12 characters

WEAK_TRUST_CONDITIONS

A web-identity trust must name exactly which workload may use it, and these conditions do not.

Web-identity federation lets tokens from an OpenID Connect provider, such as a CI platform, be exchanged for a role session. Its trust conditions must pin the token's subject with a StringEquals or StringLike entry on token.sub whose values are nonempty and do not start with a wildcard; otherwise every workload the provider issues tokens for could assume the role.

How to fix: add a token.sub condition naming the specific subject, for example one repository and branch (web-identity federation).

HTTP 400 · thrown by @better-iam/server

Example messages:

  • Web identity trusts must pin token.sub with StringEquals or StringLike (no leading wildcard)

401 Unauthenticated

Better IAM could not establish who is calling, or the proof it was given is no longer good enough: the session expired or was revoked, the key is invalid, or a code was wrong. Send the person back through sign-in (or step-up) and retry.

CodeMeaning
DELEGATION_TOKEN_INVALIDA delegation token did not verify, or the delegation behind it has ended.
INVALID_ASSERTIONA stateless assertion failed verification.
INVALID_CHALLENGEA one-time link, code, or sign-in step is wrong, expired, or already used.
INVALID_CREDENTIALSThe email and password, or the current password, are wrong, or the account cannot sign in.
INVALID_MFAThe authenticator code or recovery code is wrong, expired, or already used.
INVALID_PASSKEYThe passkey response could not be verified or does not belong to this account.
INVALID_TOKENThe OAuth access token, or its DPoP proof, is missing, invalid, or expired.
OAUTH_PROFILEThe identity provider's answer did not identify the person.
SAML_INVALIDThe SAML response could not be accepted.
SESSION_NETWORK_MISMATCHThe session is bound to the network it was signed in from and was used from a different one.
UNAUTHENTICATEDThe request has no valid credential, or its credential has expired or been revoked.

DELEGATION_TOKEN_INVALID

A delegation token did not verify, or the delegation behind it has ended.

iam.a2a.verifyDelegationToken answers it (401) when the token is not a biam-delegation+jwt signed with the deployment's card keys, when its issuer, audience, organization, or times do not match, or when it lives longer than an hour. With live: true it also answers it when anything the token stands on has changed:

  • the delegation or one above it was revoked or has expired;
  • the person or an agent in the chain is no longer in good standing;
  • the acting agent's API key was revoked, or the agent no longer accepts delegation;
  • the audience left an agent's tokenAudiences;
  • a limit no longer allows a scope.

It also answers it when a replay check refuses a token seen before.

How to fix: the agent gets a new token with delegations.issueToken for the right audience; when the delegation has ended, the person grants a new one.

HTTP 401 · thrown by @better-iam/server

INVALID_ASSERTION

A stateless assertion failed verification.

verifyAssertion (and the Next.js edge and NestJS assertion helpers) refuses a token that is malformed, signed with another key, meant for another audience or issuer, expired, or not yet valid.

How to fix: issue a fresh assertion with assertions.issue for the right audience, and verify with every current key from iam.assertionKeys() while a secret rotates (stateless assertions).

HTTP 401 · thrown by @better-iam/server

INVALID_CHALLENGE

A one-time link, code, or sign-in step is wrong, expired, or already used.

It covers email verification, password-reset, and email-change links, passwordless and phone verification codes, the sign-in challenge between the password and the second factor, MFA enrollment, and passkey ceremonies. A passwordless code also fails when the email or phone changed after it was sent.

How to fix: start the step again to get a new link or code; retrying the same one fails the same way.

HTTP 401 · thrown by @better-iam/auth

Example messages:

  • Invalid verification code
  • Challenge is invalid or expired
  • MFA enrollment is invalid or expired
  • Invalid passkey challenge
  • Sign-in challenge is invalid or expired
  • Email has changed

INVALID_CREDENTIALS

The email and password, or the current password, are wrong, or the account cannot sign in.

auth.signIn gives the same answer for an unknown email, a wrong password, and a disabled account, so responses never reveal which accounts exist; auth.changePassword and auth.reauthenticate use it for a wrong password. Failed attempts count toward rate limits and are recorded.

How to fix: show a generic "email or password is incorrect" message on the form and let the person retry or reset their password.

HTTP 401 · thrown by @better-iam/auth

Example messages:

  • Invalid current password
  • Invalid password
  • Authentication is unavailable for this account
  • Invalid email or password

INVALID_MFA

The authenticator code or recovery code is wrong, expired, or already used.

Authenticator (TOTP) codes must be six digits and each time step is accepted once, so a replayed code fails; emailed codes expire, and each recovery code works once. Failures count toward the sign-in rate limit.

How to fix: enter a fresh code from the authenticator app or email, or an unused recovery code (MFA).

HTTP 401 · thrown by @better-iam/auth

Example messages:

  • Invalid MFA code
  • Invalid or previously used MFA code
  • Invalid recovery code

INVALID_PASSKEY

The passkey response could not be verified or does not belong to this account.

Registration and sign-in responses are checked against the stored challenge, the relying party, user verification, and the registered credential; a passkey registered for another identity or organization is refused.

How to fix: start the ceremony again and use a passkey registered for this account in this organization (passkeys).

HTTP 401 · thrown by @better-iam/auth

Example messages:

  • Passkey registration verification failed
  • Passkey verification failed
  • Passkey is not registered for this organization
  • Passkey user handle does not match this identity
  • Passkey does not belong to this tenant identity
  • Passkey authentication verification failed

INVALID_TOKEN

The OAuth access token, or its DPoP proof, is missing, invalid, or expired.

Resource servers built with createAccessTokenVerifier or createResourceGuard refuse a missing or expired token, a replayed or mismatched DPoP proof, and a sender-constrained token sent without its proof, and answer with a WWW-Authenticate challenge naming invalid_token. The browser client also raises it, before sending, for a configured bearer token with an invalid format.

How to fix: obtain a new access token (refresh it or sign in again) and send it with the right scheme (resource servers).

HTTP 401 · thrown by @better-iam/oauth

OAUTH_PROFILE

The identity provider's answer did not identify the person.

The sign-in fails when the profile request fails or returns something unusable, when the profile or OIDC ID token names no subject, or when a Microsoft ID token names no directory.

How to fix: retry the sign-in; if it keeps failing, check the connection's provider settings, scopes, and any mapProfile function (providers).

HTTP 401 · thrown by @better-iam/oauth

Example messages:

  • The Microsoft ID token names no directory.
  • Provider profile request failed.
  • Provider subject is missing.
  • Provider profile is invalid.
  • OIDC subject is missing.

SAML_INVALID

The SAML response could not be accepted.

Better IAM rejects responses that are unsafe or malformed XML, fail the signature, issuer, audience, destination, recipient, or age checks, were already used, are unsolicited when the connection does not allow IdP-initiated sign-in, or lack a required encrypted assertion. The assertion consumer service answers every such failure the same way, so an attacker learns nothing from it.

How to fix: start the sign-in again from your application; if it keeps failing, compare the identity provider's settings (entity ID, ACS URL, certificates, encryption) with the connection (SAML).

HTTP 400, 401, 413, 415 · thrown by @better-iam/saml

Example messages:

  • Unsafe SAML document.
  • SAML response binding is invalid.
  • An encrypted assertion is required.
  • SAML subject recipient is invalid.
  • An unsolicited response cannot answer a request.
  • Invalid SAML response.

SESSION_NETWORK_MISMATCH

The session is bound to the network it was signed in from and was used from a different one.

A tenant's authPolicy.bindSessionsToIp makes a user session usable only from the client IP it was issued from, so a stolen session cookie or token does not work elsewhere; the refused attempt is recorded. Requests without a recorded client IP are not judged.

How to fix: sign in again from the current network; the browser client calls its onUnauthenticated hook for this code (binding sessions to their network).

HTTP 401 · thrown by @better-iam/auth, @better-iam/server

Example messages:

  • This session can only be used from the network it was signed in from

UNAUTHENTICATED

The request has no valid credential, or its credential has expired or been revoked.

It covers a missing or malformed session cookie or bearer token, an expired or idle session, a revoked session or API key, a disabled or expired identity, a role session or session token whose source was revoked, and an impersonation session whose administrator's session ended.

How to fix: send the person to sign in again; the browser client calls its onUnauthenticated hook for this code, and framework integrations treat the request as signed out (sessions).

HTTP 401 · thrown by @better-iam/auth, @better-iam/nestjs, @better-iam/server

Example messages:

  • Original session is no longer valid
  • Invalid authentication
  • Impersonation has ended
  • Invalid credentials
  • Invalid or missing credentials
  • Invalid or expired credentials

403 Forbidden

The caller is known, but this action is not allowed for them: no role or policy grants it, a deny or boundary blocks it, or a tenant policy (MFA, network, sign-in method) refuses the request. Retrying will not help until access or context changes.

CodeMeaning
ACCESS_DENIEDThe caller is signed in but is not allowed to perform this action.
CATALOG_LOCKEDOrganizations cannot register their own resource types or actions on this deployment.
CSRFA protocol endpoint that acts on a signed-in session was called without a trusted Origin or the X-Better-IAM header.
CSRF_REJECTEDThe request did not carry the headers that prove it came from your application rather than another site.
DELEGATION_INACTIVEThe delegation can no longer be used to open delegated sessions.
DELEGATION_NOT_ALLOWEDThe AI agent does not accept delegation.
EMAIL_UNVERIFIEDThe account's email address is not verified, and this deployment requires verification before sign-in.
FEATURE_DISABLEDThe feature this call needs is turned off or not configured on this deployment or for this organization.
FORBIDDENSelf-registration was attempted in the root tenant, where only administrators create accounts.
GRANT_AUTHORITY_REQUIREDThe caller has permission for the action but holds no active grant authority to issue access under.
HOST_MISMATCHThe request arrived on one organization's sign-in address but names or authenticates as another organization.
IMPERSONATION_RESTRICTEDThe operation is not available while an administrator is impersonating a member.
INSUFFICIENT_SCOPEThe OAuth access token is valid but lacks a scope the resource requires.
INVALID_LINKThe identities cannot be linked, or the link or linking proof is no longer valid.
INVALID_SESSIONOAuth consent was attempted with a credential that is not a person's signed-in session.
INVALID_TENANT_TREEThe organization's chain of parent tenants contains a cycle, so authentication refuses it.
IP_BLOCKEDRequests from this network are blocked for the organization or for the whole platform.
IP_NOT_ALLOWEDThe organization only allows sign-in from certain networks, and this address is not one of them.
METHOD_NOT_ALLOWEDThe organization does not permit this sign-in method, or the HTTP method is not supported on this route.
MFA_NOT_ENROLLEDThe operation needs a second factor, but the person has not enrolled one.
MFA_REQUIREDThe session has not completed multi-factor authentication, and this action or organization requires it.
OAUTH_TENANTThe person's Microsoft directory is not allowed to sign in through this connection.
PASSWORD_EXPIREDThe password is correct but older than the organization's maximum password age.
PROTECTED_OPERATIONAn OAuth client was about to be stored outside the authenticated registration path.
PROTECTED_RESOURCEThe target is a protected owner role or policy, or belongs to a higher grant authority, and cannot be changed this way.
RECENT_AUTH_REQUIREDThe operation is sensitive, and the session was not authenticated recently enough.
RESOURCE_MISMATCHYour resolveResource callback returned nothing, or a resource that does not match the one being authorized.
SELF_REVIEWA reviewer tried to certify their own access in an access review.
TENANT_INACTIVEThe organization, or one of its parent tenants, is suspended, pending, or deleted.
TENANT_MISMATCHThe signed-in person belongs to a different organization than the OAuth client asking for consent.
TENANT_UNAVAILABLEThe organization is not active, so nobody can sign in to it.
UNTRUSTED_ORIGINThe request came from a browser origin that the deployment does not trust.
WEB_IDENTITY_REJECTEDThe external token was not accepted by sts.assumeRoleWithWebIdentity, for a reason the response deliberately does not name.

ACCESS_DENIED

The caller is signed in but is not allowed to perform this action.

Every API operation checks an iam:* permission (for example iam:groups:update) and, when no role or policy grants it or a deny or boundary blocks it, records a deny audit event and fails with this code; iam.require and the framework guards throw it for your own actions too. It also covers specific refusals: approving your own access request, granting a role beyond your grant authority, changing a binding issued under a higher authority, cancelling someone else's request, or acting from a session that is not an ordinary session of the tenant.

How to fix: ask an administrator for a role that grants the action. To see why a decision was made, use policies.simulate or accessPaths.find.

HTTP 400, 403 · thrown by @better-iam/cli, @better-iam/nestjs, @better-iam/server

Example messages:

  • Not allowed${decision.reason ?
  • Access denied
  • Access paths are for an ordinary session of the tenant
  • A request cannot be approved by its requester
  • Cannot grant role …
  • Only the requester can cancel a request

CATALOG_LOCKED

Organizations cannot register their own resource types or actions on this deployment.

resourceTypes.register and actions.register need permissions.mode: 'tenant-defined' in the server options; in catalog mode only your configuration defines the catalog.

How to fix: declare the types and actions under permissions in your configuration, or switch to tenant-defined mode if organizations should define their own (catalog).

HTTP 403 · thrown by @better-iam/server

Example messages:

  • Tenant-defined resource types are disabled
  • Tenant-defined actions are disabled

CSRF

A protocol endpoint that acts on a signed-in session was called without a trusted Origin or the X-Better-IAM header.

The OAuth and SAML sign-in handlers start account linking only for a POST with an Origin from trustedOrigins, the X-Better-IAM: 1 header, and a JSON content type, and the OAuth provider's completeInteraction requires a trusted Origin on its consent POST. This stops other sites from starting these flows with the person's cookies.

How to fix: send the request with fetch from your own origin (the browser sets Origin), include the header, and list the origin in trustedOrigins (CSRF and Origin checks).

HTTP 403 · thrown by @better-iam/oauth, @better-iam/saml

Example messages:

  • Linking requires a trusted Origin and X-Better-IAM header.
  • A trusted Origin is required.

CSRF_REJECTED

The request did not carry the headers that prove it came from your application rather than another site.

API calls must be POST requests with Content-Type: application/json and X-Better-IAM: 1, and a request that carries cookies must also send an Origin header. The SCIM administration handler additionally rejects cross-origin requests, and the Next.js, NestJS, and middleware integrations apply the same cookie rule to your own routes.

How to fix: call the API through the browser client, which sets both headers, or add them yourself (CSRF and Origin checks).

HTTP 403 · thrown by @better-iam/next, @better-iam/scim, @better-iam/server

Example messages:

  • Cookie requests require Origin
  • JSON requests require X-Better-IAM: 1.
  • Cross-origin requests are rejected.
  • JSON and X-Better-IAM header required

DELEGATION_INACTIVE

The delegation can no longer be used to open delegated sessions.

delegations.assume refuses a delegation that was denied or revoked, a request that lapsed undecided, a delegation past its end, and one whose person is no longer an active, unexpired member of the tenant.

How to fix: have the person grant a new delegation, or ask for one with delegations.request; delegations.get shows the status and whether it expired.

HTTP 403 · thrown by @better-iam/server

Example messages:

  • This delegation is no longer active
  • A delegation above this one has ended
  • The person behind this delegation is not active

DELEGATION_NOT_ALLOWED

The AI agent does not accept delegation.

Its profile sets delegable: false, so delegations.grant, delegations.request, delegations.approve, and delegations.assume refuse it, and its existing delegated sessions are refused (as UNAUTHENTICATED) until delegation is turned back on.

How to fix: an administrator sets delegable: true with agents.update.

It also answers delegation limits that are not the agent's switch: a hand-off the person did not allow (delegations.handoff), and a delegation token for an audience outside the tokenAudiences of an agent in the chain, or for a scope some limit on the session does not allow outright (delegations.issueToken). Name an allowed audience or narrower scopes, or have an administrator add the audience to the agents' profiles.

HTTP 403 · thrown by @better-iam/server

Example messages:

  • This agent does not accept delegation
  • The person did not allow this delegation to be handed on
  • An agent cannot receive a hand-off from its own delegation chain
  • The person did not allow hand-offs to this agent
  • Agent … may not present delegations to …

EMAIL_UNVERIFIED

The account's email address is not verified, and this deployment requires verification before sign-in.

With authentication.requireEmailVerification (on by default when self-registration is enabled), sign-in, session issue, and every later use of a session refuse unverified identities.

How to fix: have the person open the verification link from their email (auth.verifyEmail), or send a new one with auth.requestEmailVerification (email verification).

HTTP 403 · thrown by @better-iam/auth

Example messages:

  • Verify your email before signing in

FEATURE_DISABLED

The feature this call needs is turned off or not configured on this deployment or for this organization.

Examples: self-registration without signUpEnabled, password sign-in or recovery with emailPassword: false or no sendEmail, a passwordless channel or passkeys that are not configured, passkey sign-in for an account without a passkey, emailed MFA codes the sign-in does not offer, a delivery kind without its callback, impersonation in a tenant whose policy does not set allowImpersonation, session JWTs without sts.jwt, custom hostnames without hosts.customHostnames, passkeys on a custom hostname outside the passkey domain (the RP ID), and the inference API group and iam.inference without the inference option.

How to fix: enable the option in your configuration or the tenant's authentication policy, or hide the feature in your UI while it is off.

HTTP 400, 403 · thrown by @better-iam/auth, @better-iam/server

Example messages:

  • … delivery is not configured
  • Delivery callback unavailable
  • Email delivery is not configured
  • Password recovery is unavailable
  • Password authentication is disabled
  • Impersonation is not enabled for this organization

FORBIDDEN

Self-registration was attempted in the root tenant, where only administrators create accounts.

auth.signUp refuses the platform's root tenant, because anyone who registered there would join the tenant that operates the whole platform.

How to fix: sign people up in an organization tenant, and create root identities with bootstrap, recover-root, or an administrator.

HTTP 403 · thrown by @better-iam/auth

Example messages:

  • Root identity provisioning requires an administrator

GRANT_AUTHORITY_REQUIRED

The caller has permission for the action but holds no active grant authority to issue access under.

Operations that grant access or create grantable records (bindings, roles, policies, group memberships, invitations, organizations, API keys) record the grant authority they were issued under. A non-root administrator needs one that is not revoked and whose delegation chain is intact.

How to fix: ask a root administrator, or someone holding a broader authority, to delegate one with authorities.create.

HTTP 403 · thrown by @better-iam/server

Example messages:

  • An active delegated grant authority is required

HOST_MISMATCH

The request arrived on one organization's sign-in address but names or authenticates as another organization.

With organization addresses (hosts), a request on acme.signin.example.com or Acme's custom hostname is pinned to Acme: a sign-in call naming another tenantId, a session or API key of another organization, and a page on one organization's address calling the API on another's are all refused. Root administrators sign in at the deployment's own address, not an organization's.

How to fix: send the person to their own organization's address (its signInUrl from tenants.lookup), or leave tenantId out and let the address decide (sign-in addresses).

HTTP 403 · thrown by @better-iam/auth, @better-iam/server

Example messages:

  • This address belongs to another organization
  • This page and this address belong to different organizations

IMPERSONATION_RESTRICTED

The operation is not available while an administrator is impersonating a member.

An impersonation ("view as") session never counts as recently authenticated, so every operation that needs recent authentication refuses it. Decisions made in the member's name are refused too: approving requests or activations, reviewing access, accepting agreements, granting OAuth consent, requesting packages, assuming roles, change previews, and starting another impersonation.

How to fix: end the impersonation and act from your own session (impersonation).

HTTP 403 · thrown by @better-iam/auth, @better-iam/oauth, @better-iam/server

Example messages:

  • An impersonated session cannot be re-authenticated
  • Only a person acting through their own session can impersonate
  • This operation is unavailable while impersonating a member
  • Consent cannot be granted while impersonating a member.
  • Access paths are not available while impersonating
  • Agreements cannot be accepted while impersonating

INSUFFICIENT_SCOPE

The OAuth access token is valid but lacks a scope the resource requires.

Resource servers built with createAccessTokenVerifier or createResourceGuard check the scopes they were configured with and answer with a WWW-Authenticate challenge naming insufficient_scope; the message lists the missing scopes.

How to fix: have the client request the missing scopes in a new authorization, and make sure it is allowed to (resource servers).

HTTP 403 · thrown by @better-iam/oauth

Example messages:

  • Missing scope: ….

The identities cannot be linked, or the link or linking proof is no longer valid.

links.create links only ordinary user identities of two different tenants, and linked onboarding needs an ordinary user session. links.switch refuses a link that no longer holds, and a federated linking callback refuses a proof whose session or identity changed.

How to fix: link from two signed-in user sessions in different tenants, and start a linking flow again if its session ended.

HTTP 400, 403 · thrown by @better-iam/server

Example messages:

  • Invalid verified account-linking proof
  • Linked onboarding requires an ordinary user session
  • Only separate ordinary tenant identities can link
  • Only ordinary user identities can link
  • Invalid identity link

INVALID_SESSION

OAuth consent was attempted with a credential that is not a person's signed-in session.

The OAuth provider's completeInteraction requires a user session, so API keys and temporary credentials cannot grant consent on anyone's behalf.

How to fix: sign the person in to the client's organization and pass their session cookie or token (OAuth provider).

HTTP 403 · thrown by @better-iam/oauth

Example messages:

  • A user session is required.

INVALID_TENANT_TREE

The organization's chain of parent tenants contains a cycle, so authentication refuses it.

Before sign-in and on every session use, the authentication service walks the tenant and its ancestors; a tenant that is its own ancestor can only come from data changed outside Better IAM. The Next.js integration treats it as a signed-out request.

How to fix: repair the tenants' parentId values (for example from a backup), then sign in again.

HTTP 403 · thrown by @better-iam/auth

Example messages:

  • Invalid tenant hierarchy

IP_BLOCKED

Requests from this network are blocked for the organization or for the whole platform.

Administrators block networks with security.blockNetwork. A blocked address is refused before any credential is checked (so no rate-limit counter moves), and existing sessions from it stop working at their next use.

How to fix: connect from another network, or ask an administrator to lift the block with security.unblockNetwork or wait for it to expire (network blocks).

HTTP 403 · thrown by @better-iam/auth

Example messages:

  • Sign-in from this network is blocked

IP_NOT_ALLOWED

The organization only allows sign-in from certain networks, and this address is not one of them.

A tenant's authPolicy.allowedIpRanges is checked when a session is issued and on every later use, including role sessions derived from it; requests without a known client address are not judged.

How to fix: connect from an allowed network (for example the company VPN), or ask an administrator to add the range (IP allowlist).

HTTP 403 · thrown by @better-iam/auth

Example messages:

  • Sign-in from this network is not permitted for this organization

METHOD_NOT_ALLOWED

The organization does not permit this sign-in method, or the HTTP method is not supported on this route.

With status 403 it comes from a tenant's authPolicy.allowedMethods, for example a tenant that allows only federated sign-in refusing a password. With status 405 an API, SCIM, or webhook route was called with an unsupported HTTP method; API calls are POST.

How to fix: offer the person a method the organization allows (restricting sign-in methods), or send the request as POST.

HTTP 403, 405 · thrown by @better-iam/auth, @better-iam/scim, @better-iam/server

Example messages:

  • This sign-in method is not permitted for this organization
  • Use POST.
  • Use POST
  • Method is not supported

MFA_NOT_ENROLLED

The operation needs a second factor, but the person has not enrolled one.

auth.verifyMfa refuses a code when the person has no authenticator app and no emailed code was requested for this sign-in. Regenerating recovery codes, and an MFA step-up on an existing credential (such as a temporary credential requested with an MFA code), need an enrolled authenticator app.

How to fix: enroll a factor first with auth.beginMfa and auth.confirmMfa (MFA).

HTTP 403 · thrown by @better-iam/auth, @better-iam/server

Example messages:

  • MFA is not enrolled
  • Only a signed-in person can verify MFA

MFA_REQUIRED

The session has not completed multi-factor authentication, and this action or organization requires it.

It is raised when the deployment or tenant requires MFA and a session without it is issued or used (for example after the organization turned the requirement on), when activating an eligible role whose rules require an MFA-verified session, when disabling MFA or regenerating recovery codes, when impersonating a member who needs MFA, and by framework step-up guards.

How to fix: send the person through the second-factor step and retry; framework integrations treat it as a signed-out request (step-up).

HTTP 403 · thrown by @better-iam/auth, @better-iam/server

Example messages:

  • Multi-factor authentication is required
  • Complete multi-factor authentication before impersonating a member who requires it
  • Use an existing MFA factor before changing enrollment
  • MFA is required by this tenant
  • Activation requires an MFA-verified session

OAUTH_TENANT

The person's Microsoft directory is not allowed to sign in through this connection.

A Microsoft connection with allowedMicrosoftTenants accepts ID tokens only from those directory ids.

How to fix: add the directory id to the connection's list if it should be accepted, or sign in with an account from an allowed directory.

HTTP 403 · thrown by @better-iam/oauth

Example messages:

  • This Microsoft directory may not sign in here.

PASSWORD_EXPIRED

The password is correct but older than the organization's maximum password age.

A tenant's authPolicy.passwordMaxAgeDays makes sign-in refuse a password that was not changed within that many days.

How to fix: send the person through password reset (auth.requestPasswordReset, then auth.resetPassword) (password reset).

HTTP 403 · thrown by @better-iam/auth

Example messages:

  • Your password has expired; reset it to continue

PROTECTED_OPERATION

An OAuth client was about to be stored outside the authenticated registration path.

Better IAM's OAuth provider stores clients only through issuer.registerClient(), which checks the caller's permission, or through a dynamic registration admitted for the same tenant; any other attempt to write a client is refused.

How to fix: register clients with registerClient (OAuth provider).

HTTP 403 · thrown by @better-iam/oauth

Example messages:

  • Use authenticated registerClient().

PROTECTED_RESOURCE

The target is a protected owner role or policy, or belongs to a higher grant authority, and cannot be changed this way.

Owner roles cannot be requested, approved, bound, packaged, inherited, or assumed through trust, and protected roles and the owner policy cannot be edited or deleted; ownership moves only through identities.setOwner. Roles and policies created under a superior authority can be edited only by that authority.

How to fix: use owner transfer for ownership, and ask the administrator whose authority created the role or policy to make the change.

HTTP 400, 403 · thrown by @better-iam/server

Example messages:

  • Owner roles cannot be requested
  • Owner roles cannot be granted this way
  • Use owner transfer for protected roles
  • Use owner transfer
  • Protected roles cannot be packaged
  • Owner policy is protected

RECENT_AUTH_REQUIRED

The operation is sensitive, and the session was not authenticated recently enough.

Password, email, factor, session, credential, ownership, and policy changes, impersonation, and similar operations need a session established within authentication.recentAuthenticationMs (five minutes by default). Temporary credentials such as assumed-role sessions and session tokens never qualify, and OAuth or SAML account linking needs a sign-in within the last five minutes.

How to fix: reauthenticate (for example auth.reauthenticate with the password, completing MFA if asked) and retry (recent authentication).

HTTP 403 · thrown by @better-iam/auth, @better-iam/oauth, @better-iam/saml

Example messages:

  • Temporary credentials cannot perform this operation; use a signed-in session
  • Reauthenticate to perform this operation
  • Linking requires recent authentication to a non-root identity in this tenant.

RESOURCE_MISMATCH

Your resolveResource callback returned nothing, or a resource that does not match the one being authorized.

For application resource types, Better IAM asks your callback for the resource and requires the returned tenantId, type, and id to equal the request's, so a faulty lookup can never authorize one tenant's request with another tenant's record. The request is refused instead.

How to fix: return exactly the requested resource from resolveResource (resources and catalog).

HTTP 403 · thrown by @better-iam/server

Example messages:

  • Resource ownership mismatch

SELF_REVIEW

A reviewer tried to certify their own access in an access review.

Certification campaigns refuse a reviewer's decision on an item that grants the reviewer access, directly or through one of their groups.

How to fix: leave the item to another reviewer of the campaign (certifications).

HTTP 403 · thrown by @better-iam/server

Example messages:

  • Reviewers cannot certify their own access; ask another reviewer

TENANT_INACTIVE

The organization, or one of its parent tenants, is suspended, pending, or deleted.

Every API call re-checks the caller's tenant ancestry, so suspending an organization stops its sessions at their next request. Creating identities or invitations, opening access requests, moving a tenant under an inactive parent, and the OAuth, SAML, SCIM, and shared-signals endpoints refuse inactive tenants too; framework integrations treat it as a signed-out request.

How to fix: reactivate the organization and its parents with tenants.setStatus, or finish onboarding a pending one.

HTTP 400, 403 · thrown by @better-iam/oauth, @better-iam/saml, @better-iam/scim, @better-iam/server

Example messages:

  • Tenant is unavailable.
  • Tenant unavailable.
  • Tenant must be active
  • The organization is not active
  • New parent ancestry must be active
  • Parent must be active

TENANT_MISMATCH

The signed-in person belongs to a different organization than the OAuth client asking for consent.

The OAuth provider's completeInteraction accepts consent only from a user session in the client's tenant.

How to fix: sign the person in to the client's organization before the consent step (OAuth provider).

HTTP 403 · thrown by @better-iam/oauth

Example messages:

  • Sign in to the client tenant before continuing.

TENANT_UNAVAILABLE

The organization is not active, so nobody can sign in to it.

Authentication checks the tenant and every ancestor before sign-in, sign-up, and recovery and on every session use; a missing, suspended, pending, or deleted tenant anywhere in the chain is refused. Framework integrations treat it as a signed-out request.

How to fix: check that the person chose the right organization, and ask the platform operator to reactivate it if it was suspended.

HTTP 403 · thrown by @better-iam/auth

Example messages:

  • Tenant is not active

UNTRUSTED_ORIGIN

The request came from a browser origin that the deployment does not trust.

A request with an Origin header must name an exact origin from trustedOrigins (the origin of baseURL is always included); the Next.js and middleware integrations apply the same rule to cookie requests on your routes.

How to fix: add the origin (scheme, host, and port) to trustedOrigins in your configuration.

HTTP 403 · thrown by @better-iam/next, @better-iam/server

Example messages:

  • Origin is not trusted

WEB_IDENTITY_REJECTED

The external token was not accepted by sts.assumeRoleWithWebIdentity, for a reason the response deliberately does not name.

The public exchange answers every refusal that depends on stored state or on the token with this same 403 body: an unknown or revoked trust, a disabled provider, a bad signature, issuer, audience, type, or lifetime, a token that is expired or too old, unmet trust conditions, a missing source identity claim, a token already redeemed, an inactive service account, a revoked authority, or an inactive tenant. Keeping them identical stops callers from discovering which trusts, roles, and providers exist.

How to fix: an administrator runs trust.evaluateWebIdentity with the same token, which reports the reason and each failing condition, and checks the audit log for role:assumed-with-web-identity denials, whose metadata carries the reason.

HTTP 403 · thrown by @better-iam/server

Example messages:

  • The web identity token was not accepted

404 Not found

The record does not exist in this tenant. Better IAM also answers 404 for records in other tenants, so responses never reveal what exists elsewhere.

CodeMeaning
NOT_FOUNDThe record does not exist in this tenant.
OAUTH_CLIENTThe OAuth client is unknown, revoked, or belongs to an organization that is not active.
TENANT_NOT_FOUNDAn identity was about to be created in a tenant that does not exist.

NOT_FOUND

The record does not exist in this tenant.

Better IAM also answers 404 for records that belong to another tenant, so responses never reveal what exists elsewhere. Deleted identities, unregistered managed resources, unknown routes, and the public tenants.lookup and domains.discover calls for unknown or inactive organizations answer the same way.

How to fix: check the id and the tenantId you passed, and refresh lists that may be stale.

HTTP 404 · thrown by @better-iam/auth, @better-iam/cli, @better-iam/core, @better-iam/oauth, @better-iam/projects, @better-iam/saml, @better-iam/scim, @better-iam/server

Example messages:

  • Passkey not found
  • Device not found
  • Session not found
  • Endpoint not found
  • Cannot update a missing record
  • OAuth connection not found.

OAUTH_CLIENT

The OAuth client is unknown, revoked, or belongs to an organization that is not active.

When Better IAM acts as an OAuth provider, it refuses authorization, consent, and client changes for such clients.

How to fix: check the client_id, register a new client if it was revoked, or reactivate the organization (OAuth provider).

HTTP 404 · thrown by @better-iam/oauth

Example messages:

  • Client is unavailable.

TENANT_NOT_FOUND

An identity was about to be created in a tenant that does not exist.

It is a safety check inside the low-level identity creation that sign-up, invitations, federation, and root recovery share. Those flows check the tenant first and normally answer TENANT_UNAVAILABLE or NOT_FOUND instead.

How to fix: check the tenantId you passed.

HTTP 404 · thrown by @better-iam/auth

Example messages:

  • Tenant does not exist

409 Conflict

The request conflicts with the current state: a duplicate name or email, a version that changed since it was read, or a one-time token that was already used. Reload the current state and decide again.

CodeMeaning
ACCOUNT_LINK_CONFLICTThe external account you are linking is already linked to a different account in this tenant.
ACCOUNT_LINK_REQUIREDA first sign-in through an external provider used an email that already belongs to an account in this tenant, so the accounts must be linked explicitly.
ALREADY_INITIALIZEDBootstrap was run on a database that already has a root tenant.
ARCHIVE_CONFLICTThe audit archive already holds a file for this batch of events with different contents.
BILLING_PERIOD_CLOSEDThe month has already been invoiced for this billing account, so its usage and prices can no longer change.
CONFLICTThe request conflicts with a record that already exists or with the record's current state.
DATABASE_IN_USECode running inside a transaction tried to use the same SQLite or libSQL database through a second adapter instance.
DELEGATION_EXISTSAn AI agent and a person already have a pending request or an active delegation between them.
DELEGATION_PENDINGThe person has not approved this delegation yet.
DOMAIN_TAKENAnother organization has already verified this email domain.
FEATURE_LOCKEDA tenant tried to choose its own value for a feature flag (features.setOverride) that the flag's owners decide for it.
HOSTNAME_TAKENAnother organization has already verified this custom hostname.
IDENTITY_EXISTSAn identity with this email address already exists in the tenant.
INVALID_IDENTITYThe identity involved is not in a state that allows this operation.
INVALID_TRANSITIONThe record is not in a state that allows this step.
INVARIANT_VIOLATIONThe change would break an enforced access invariant, so it was rolled back.
LAST_AUTHENTICATORDeleting this passkey would leave the account with no way to sign in.
LAST_OWNERThe operation would leave the organization without an active owner.
LAST_ROOT_ADMINThe operation would leave the platform without an active root administrator.
LIMIT_EXCEEDEDThe tenant has reached a limit on how many of these records it may have.
METER_ARCHIVEDThe billing meter is archived and accepts no new usage.
MFA_ALREADY_ENABLEDThe person already has an authenticator app enrolled.
PASSKEY_EXISTSThis passkey is already registered.
PHONE_EXISTSAnother person in this organization has already verified this phone number.
RESOURCE_IN_USEThe record is still referenced by other records, so it cannot be deleted or changed this way.
SAME_DATABASEThe source and target of a store copy are the same database.
SLUG_TAKENAnother organization already uses this slug.
SOD_CONFLICTThe change would give one person a combination of roles that a separation-of-duties rule forbids.
STORE_NOT_EMPTYThe target database of an import or copy already holds records.
TEAM_MANAGEDThe group belongs to a team, and its members come from the team.
TRANSACTION_ABORTEDA database operation failed inside a transaction, so the whole transaction was rolled back.
VERSION_CONFLICTThe record changed since you read it, so your change or acceptance was refused.

The external account you are linking is already linked to a different account in this tenant.

It happens at the callback of an OAuth or SAML linking flow when the provider identity (provider, issuer, and subject) already belongs to another identity. A provider identity links to one account at a time, and links never move silently.

How to fix: sign in with that provider to reach the account it already belongs to (link an existing account).

HTTP 409 · thrown by @better-iam/server

Example messages:

  • External identity is already linked to another account

A first sign-in through an external provider used an email that already belongs to an account in this tenant, so the accounts must be linked explicitly.

Better IAM never merges accounts by email, because anyone who could set that address at some provider could otherwise take over the account.

How to fix: sign in to the existing account (password, passkey, or another linked provider), reauthenticate, and start the provider's linking flow from that session; later sign-ins with the provider then reach that account (link an existing account).

HTTP 409 · thrown by @better-iam/server

Example messages:

  • Authenticate the existing account before linking this provider

ALREADY_INITIALIZED

Bootstrap was run on a database that already has a root tenant.

bootstrap and the better-iam bootstrap command create the root tenant and the first root administrator exactly once.

How to fix: nothing needs creating. Sign in with an existing root administrator, or run recover-root if you lost access to all of them (recovering root access).

HTTP 409 · thrown by @better-iam/server

Example messages:

  • Root already exists

ARCHIVE_CONFLICT

The audit archive already holds a file for this batch of events with different contents.

The JSONL archive sink writes each batch once, named by tenant and sequence range, and never replaces an existing file. A different batch under the same name means two deployments share one archive directory, or the database's audit chain diverged from what was archived earlier (for example after restoring an old backup). archiveAudit reports it per tenant in its failed list, and audit-archive then fails with AUDIT_ARCHIVE_FAILED.

How to fix: keep the existing file as evidence, give each deployment its own archive directory, and investigate why the chain changed (continuous audit archiving).

HTTP 409 · thrown by @better-iam/server

Example messages:

  • …/… already holds different events

BILLING_PERIOD_CLOSED

The month has already been invoiced for this billing account, so its usage and prices can no longer change.

billing.record (and iam.billing.record) refuse usage whose occurredAt falls in a month with a statement for the account, and billing.setPrice refuses an effectiveFrom that reaches back into an invoiced month.

How to fix: record late usage in the current month, or have a root administrator void the statement (billing.voidStatement), record the correction, and close the month again (billing).

HTTP 409 · thrown by @better-iam/server

Example messages:

  • A period from effectiveFrom on has already been invoiced; invoiced periods keep their prices
  • … has already been invoiced; leave period out to bill the next invoice
  • … has already been invoiced for this billing account

CONFLICT

The request conflicts with a record that already exists or with the record's current state.

Typical causes are a duplicate (an agreement, invariant, access package, resource type, or action with the same name, a resource or domain that is already registered or claimed, a binding or group membership that already exists, an identical pending request), acting on a certification campaign that is closed, and reusing an invitation that was consumed or revoked. The storage layer also raises it for any duplicate id or tenant-unique key and for an attempt to move a record to another tenant.

How to fix: reload the current state and decide again; for a duplicate, use the existing record or choose another name.

HTTP 409 · thrown by @better-iam/core, @better-iam/oauth, @better-iam/projects, @better-iam/saml, @better-iam/scim, @better-iam/server

Example messages:

  • Record tenant cannot be changed
  • A record with this identifier or tenant key already exists
  • Client ID already exists; tenant binding is immutable.
  • A project with this name already exists
  • A SAML connection with this ID already exists.
  • A revoked connection cannot be rotated.

DATABASE_IN_USE

Code running inside a transaction tried to use the same SQLite or libSQL database through a second adapter instance.

The adapters refuse it instead of waiting forever for a lock their own caller holds. Copying a database onto itself is the usual cause, which copyStore reports as SAME_DATABASE.

How to fix: inside a transaction, read and write through the transaction's store, and share one adapter instance per database file.

HTTP 409 · thrown by @better-iam/adapter-libsql, @better-iam/adapter-sqlite

Example messages:

  • This call chain already holds this database through another adapter instance

DELEGATION_EXISTS

An AI agent and a person already have a pending request or an active delegation between them.

One live delegation links an agent and a person at a time. delegations.grant refuses a new one while the agent's request waits for the person's decision or while the person already delegates to the agent, and delegations.request refuses a second request.

How to fix: approve or deny the pending request, or revoke the existing delegation before granting a new one (delegations).

HTTP 409 · thrown by @better-iam/server

Example messages:

  • A request or delegation between this agent and person already exists

DELEGATION_PENDING

The person has not approved this delegation yet.

delegations.assume answers it for a request the person is still deciding; a request waits up to seven days.

How to fix: poll delegations.get until status becomes active (or denied), then call assume again.

HTTP 409 · thrown by @better-iam/server

Example messages:

  • The person has not approved this delegation yet

DOMAIN_TAKEN

Another organization has already verified this email domain.

A verified domain belongs to exactly one tenant, so domains.add and domains.verify refuse it once another tenant verified it first.

How to fix: if the domain is yours, the other organization must release it with domains.delete before you can verify it.

HTTP 409 · thrown by @better-iam/server

Example messages:

  • Another organization verified this domain

FEATURE_LOCKED

A tenant tried to choose its own value for a feature flag (features.setOverride) that the flag's owners decide for it.

The flag does not allow tenants to choose (tenantOverridable is off), or a locked target set by the tenant that defines the flag covers this tenant or one of its ancestors. Withdrawing an earlier choice (value: null) is always allowed.

How to fix: ask the flag's managers (root administrators for a platform flag) to allow overrides or to lift the lock. features.list shows overridable and locked for each flag.

HTTP 409 · thrown by @better-iam/server

Example messages:

  • This flag does not let tenants choose their own value
  • A locked target decides this flag for the tenant

HOSTNAME_TAKEN

Another organization has already verified this custom hostname.

A verified hostname belongs to exactly one organization, so hostnames.add and hostnames.verify refuse it once another organization verified it first.

How to fix: if the hostname is yours, the other organization must release it with hostnames.delete before you can verify it.

HTTP 409 · thrown by @better-iam/server

Example messages:

  • Another organization verified this hostname

IDENTITY_EXISTS

An identity with this email address already exists in the tenant.

Emails are unique within a tenant (another tenant may have its own identity with the same address), so sign-up, identities.create, identities.createMany, identities.invite, and email changes refuse a duplicate.

How to fix: use the existing identity or another address; a person who already has an account should sign in or reset their password instead of signing up again.

HTTP 409 · thrown by @better-iam/auth, @better-iam/server

Example messages:

  • Email is already in use in this tenant
  • An identity with this email already exists in this tenant

INVALID_IDENTITY

The identity involved is not in a state that allows this operation.

accessRequests.approve refuses a request whose requester is no longer active, and credentials.create issues API keys only to active, unexpired service accounts and to AI agents in good standing. delegations.grant and delegations.approve (409) refuse an agent that is not in good standing: suspended, expired, or with a sponsor who is no longer an active person.

How to fix: re-enable the identity (or extend its expiry) first, or issue the key to a service account rather than a person. For an agent, check agents.standing, then resume it or name an active sponsor with agents.update.

HTTP 400, 409 · thrown by @better-iam/server

Example messages:

  • Requester is not active
  • The agent is not in good standing
  • API keys require an active service account
  • API keys require an agent in good standing
  • Web-identity trusts require an active service account

INVALID_TRANSITION

The record is not in a state that allows this step.

Examples: deciding or cancelling an access request, activation, or package request that is no longer pending; activating a binding that is not eligible or that nobody can approve; re-enabling an expired identity or service account before extending expiresAt; changing a deleted tenant or making a status change its lifecycle does not allow; pinging or redelivering through a paused webhook; and ending a rule-based package assignment by hand.

How to fix: reload the record, check its current status, and take a step that status allows.

HTTP 400, 409 · thrown by @better-iam/projects, @better-iam/server

Example messages:

  • Project is not active
  • Project is not archived
  • Request is …
  • Extend or clear expiresAt before resuming an expired agent
  • Deleted tenants cannot be updated
  • The credit is already revoked

INVARIANT_VIOLATION

The change would break an enforced access invariant, so it was rolled back.

Access-changing operations (role, policy, binding, group, package, relationship, and configuration changes, among others) re-evaluate the tenant's invariants in enforce mode inside their transaction. They refuse a change that creates a new violation or makes an invariant impossible to evaluate (for example by deleting the group it names); the message names the invariant and the person affected.

How to fix: preview the change with impact.preview, then adjust the change or the invariant (access invariants).

HTTP 409 · thrown by @better-iam/server

Example messages:

  • Access invariant "…" could no longer be evaluated (…); change or delete the invariant first
  • Access invariant "…": … would … … on …/…

LAST_AUTHENTICATOR

Deleting this passkey would leave the account with no way to sign in.

auth.deletePasskey refuses the last passkey when the account has no password and no verified email or phone that the deployment's passwordless sign-in could use.

How to fix: add another sign-in method first (a password or a second passkey), then delete the old passkey.

HTTP 409 · thrown by @better-iam/auth

Example messages:

  • Configure another login method before removing the last passkey

LAST_OWNER

The operation would leave the organization without an active owner.

Deleting, disabling, offboarding, or setting an expiry on the last active owner, or removing their owner flag with identities.setOwner, is refused.

How to fix: make someone else an owner with identities.setOwner first, then retry.

HTTP 409 · thrown by @better-iam/server

Example messages:

  • Cannot remove the final active owner

LAST_ROOT_ADMIN

The operation would leave the platform without an active root administrator.

Deleting, disabling, or offboarding the last root administrator, or removing their root flag with root.setAdministrator, is refused so the platform always keeps a way in.

How to fix: grant root to another person with root.setAdministrator first.

HTTP 409 · thrown by @better-iam/server

Example messages:

  • Cannot remove the final root administrator

LIMIT_EXCEEDED

The tenant has reached a limit on how many of these records it may have.

Root administrators set plan limits per tenant with tenants.setLimits (members, service accounts, groups, roles, policies, registered resources, and webhooks), and every creation path checks them, including invitations, sign-up, federation, and SCIM. Fixed caps also apply: 50 webhooks, 50 agreements, and 100 invariants per tenant, and 5,000 bindings per certification campaign.

How to fix: remove records you no longer need, ask the platform operator to raise the limit, or narrow the campaign by role or subject type; tenants.usage shows the current counts.

HTTP 409 · thrown by @better-iam/auth, @better-iam/server

Example messages:

  • This tenant has reached its member limit (…)
  • At most … agreements
  • A tenant can define at most … meters
  • A tenant can have at most … budgets
  • At most … plans
  • A campaign covers at most … bindings; narrow it by role or subject type

METER_ARCHIVED

The billing meter is archived and accepts no new usage.

Archiving (billing.updateMeter with archived: true) keeps a meter's history and prices but stops billing.record and iam.billing.record from adding to it.

How to fix: record on the meter that replaced it, or restore the meter with archived: false (billing).

HTTP 409 · thrown by @better-iam/server

Example messages:

  • This meter no longer accepts usage

MFA_ALREADY_ENABLED

The person already has an authenticator app enrolled.

auth.beginMfa starts a new authenticator enrollment only when none is active.

How to fix: to replace the authenticator, disable MFA with auth.disableMfa where the tenant allows it, then enroll again (managing factors).

HTTP 409 · thrown by @better-iam/auth

Example messages:

  • MFA is already enabled

PASSKEY_EXISTS

This passkey is already registered.

A passkey credential can be registered once for this relying party, even across organizations.

How to fix: sign in with the existing passkey, or create a new passkey on the device and register that one.

HTTP 409 · thrown by @better-iam/auth

Example messages:

  • Passkey is already registered

PHONE_EXISTS

Another person in this organization has already verified this phone number.

auth.confirmPhoneVerification keeps verified phone numbers unique within a tenant, so SMS codes and SMS sign-in reach exactly one account.

How to fix: verify a different number.

HTTP 409 · thrown by @better-iam/auth

Example messages:

  • Phone is already verified by another identity

RESOURCE_IN_USE

The record is still referenced by other records, so it cannot be deleted or changed this way.

Examples: a role that other roles inherit or packages include, a policy still attached to a role, a group that packages grant or that approves requests, a resource type with registered resources, relationships, or child types, a resource with child resources, a package that is still assigned, and an action still used by policies and roles.

How to fix: remove or repoint the references the message names, then retry.

HTTP 400, 409 · thrown by @better-iam/server

Example messages:

  • This meter has recorded usage; archive it instead
  • Remove relationships before dropping a relation
  • Delete registered resources first
  • Delete relationships first
  • Other resource types use this type as their parent
  • Move or delete the departments below it first: …

SAME_DATABASE

The source and target of a store copy are the same database.

store-copy and copyStore would otherwise read and overwrite one database in the same run.

How to fix: point --target-config at the configuration of a different, empty database (snapshots).

HTTP 409 · thrown by @better-iam/core

Example messages:

  • The source and target are the same database

SLUG_TAKEN

Another organization already uses this slug.

Slugs are globally unique aliases that sign-in screens use to find an organization; tenants.create, tenants.setSlug, and bootstrap claim them.

How to fix: choose a different slug.

HTTP 409 · thrown by @better-iam/server

Example messages:

  • This slug is already in use

SOD_CONFLICT

The change would give one person a combination of roles that a separation-of-duties rule forbids.

Operations that grant roles (bindings, group membership, identity creation, access-request and package approvals, package assignments, and configuration apply) check the tenant's prevent rules inside their transaction and roll back when they create a new conflict. Conflicts that already existed never block unrelated work.

How to fix: remove one of the conflicting roles from the person first, or grant a different role (separation of duties).

HTTP 409 · thrown by @better-iam/server

Example messages:

  • Separation of duties (…): one person cannot hold …

STORE_NOT_EMPTY

The target database of an import or copy already holds records.

store-import and store-copy load a whole deployment in one transaction, and only into an empty, migrated database, so two deployments never mix.

How to fix: point the target configuration at a new, empty database (snapshots).

HTTP 409 · thrown by @better-iam/core

Example messages:

  • The target store already holds records (…); import into an empty, migrated database
  • The target store already holds records

TEAM_MANAGED

The group belongs to a team, and its members come from the team.

Every team owns a backing group (team:{slug}) holding the members of the team and of the teams below it. Only the teams API changes who is in it: groups.addMember, groups.updateMember, groups.removeMember, and groups.delete refuse it, and access packages, invitations, and onboarding flows cannot name it.

How to fix: add or remove people with teams.addMember / teams.removeMember, or delete the team with teams.delete. Binding roles to the backing group with bindings.create is how a team gets access, and is allowed.

HTTP 409 · thrown by @better-iam/server

Example messages:

  • This group belongs to a team; manage its members with the teams API

TRANSACTION_ABORTED

A database operation failed inside a transaction, so the whole transaction was rolled back.

Once a statement fails, the adapters mark the transaction as doomed: catching the error and continuing does not save it, and the transaction ends with this code instead of committing partial work. You meet it in plugins or custom code that swallow storage errors inside a transaction.

How to fix: let storage errors propagate, or check for the condition before writing (adapter contract).

HTTP 409 · thrown by @better-iam/adapter-libsql, @better-iam/adapter-postgres, @better-iam/adapter-sqlite

Example messages:

  • A nested database operation failed; transaction rolled back

VERSION_CONFLICT

The record changed since you read it, so your change or acceptance was refused.

policies.update takes the version you edited and refuses it when someone saved a newer one, so edits never overwrite each other. agreements.accept refuses a version that is no longer current, so nobody accepts terms they have not seen.

How to fix: reload the record, show the current version, and let the person decide or accept again.

HTTP 409 · thrown by @better-iam/server

Example messages:

  • The agreement changed; review the current version before accepting
  • Policy version changed

429 Rate limited

Too many attempts in a short time. The error carries retryAfterMs and the HTTP response a Retry-After header; wait that long before retrying.

CodeMeaning
RATE_LIMITEDThere were too many attempts in a short time, so the caller must wait before trying again.
TOO_MANY_REQUESTSThe person already has too many pending access requests.

RATE_LIMITED

There were too many attempts in a short time, so the caller must wait before trying again.

Sign-in, sign-up, recovery, verification, and MFA flows count attempts per person (and per client IP when ipAttempts is set) within a window from authentication.rateLimits, 15 minutes by default, which a tenant's authPolicy.maxAttempts can tighten. The error carries retryAfterMs, the window length and so the longest you may need to wait, and the HTTP response a Retry-After header.

How to fix: wait and tell the person when to try again; an administrator can clear a locked-out person's counters with identities.unlock (failed attempts and lockouts).

HTTP 429 · thrown by @better-iam/auth

Example messages:

  • Too many attempts; try again later

TOO_MANY_REQUESTS

The person already has too many pending access requests.

accessRequests.create allows 20 open requests per requester so reviewers are not flooded. Unlike RATE_LIMITED, waiting alone does not help.

How to fix: wait for pending requests to be decided, or cancel ones no longer needed with accessRequests.cancel, then open the new one.

HTTP 429 · thrown by @better-iam/server

Example messages:

  • Resolve pending requests before opening more

402 HTTP 402

CodeMeaning
SPEND_LIMIT_REACHEDAn enforced spend budget that covers this usage is already spent (status 402).

SPEND_LIMIT_REACHED

An enforced spend budget that covers this usage is already spent (status 402).

billing.record and iam.billing.record with enforceBudgets: true check the enforced budgets that cover the usage: tenant budgets up the tree, the person's own, and their teams' and department's. Budget standings may lag recorded usage by up to 30 seconds.

How to fix: raise or disable the budget (billing.updateBudget), wait for the next budget window, or ask the budget's owners; billing.check tells callers in advance (billing).

HTTP 402 · thrown by @better-iam/server

Example messages:

  • The budget “…” is spent

413 HTTP 413

CodeMeaning
INVALID_INPUTA field in the request is missing, has the wrong type, or is out of range.
PAYLOAD_TOO_LARGEThe request body is larger than the endpoint accepts.

INVALID_INPUT

A field in the request is missing, has the wrong type, or is out of range.

It is the general validation error of every package: an empty name, a malformed email or id, a limit outside its range, a body that is not a JSON object, or options that cannot be combined; the message names the field. A few handlers use it with status 413 for an oversized body.

How to fix: correct the input; sending the same request again fails the same way.

HTTP 400, 413 · thrown by @better-iam/auth, @better-iam/core, @better-iam/next, @better-iam/oauth, @better-iam/projects, @better-iam/saml, @better-iam/scim, @better-iam/server

Example messages:

  • Unknown credential token type
  • limit must be between 1 and 1000
  • Only active people with an email can reset a password
  • limit must be a positive integer
  • name must not be empty
  • A passkey authentication response is required

PAYLOAD_TOO_LARGE

The request body is larger than the endpoint accepts.

API calls and the SCIM administration handler accept up to 64 KiB of JSON, and the Next.js webhook handler accepts 1 MiB by default.

How to fix: send less per call, for example by splitting a batch into several requests.

HTTP 413 · thrown by @better-iam/scim, @better-iam/server

Example messages:

  • Request body is too large.
  • Request body too large

421 HTTP 421

CodeMeaning
WRONG_REGIONThe organization is served by another region's deployment (HTTP 421 Misdirected Request).

WRONG_REGION

The organization is served by another region's deployment (HTTP 421 Misdirected Request).

In a multi-region deployment (regions), each organization has a home region, and sign-in for it is served only there: tenants.lookup, domains.discover, public sign-in calls naming its tenant, and requests on its address answer this code everywhere else. An address that names the wrong region (acme.signin.eu-west-1.example.com for an organization homed in us-east-1) gets it too. The error carries region and, when one can be built, location: the organization's sign-in URL in its own region (IamClientError.region and .location in the client).

How to fix: redirect the person to location. See sign-in addresses and regions.

HTTP 421 · thrown by @better-iam/server

500 HTTP 500

CodeMeaning
INTERNAL_ERRORSomething failed on the server that Better IAM does not describe to the caller.
INVALID_HIERARCHYThe tenant hierarchy does not allow this parent and child, or the stored hierarchy is broken.
SCHEMA_VERSIONThe database was created by an incompatible version of Better IAM.
STORAGE_CORRUPTA stored record could not be decoded.
STORAGE_ERRORA database operation failed for a reason other than a conflict or a busy database.
STORE_CLOSEDThe storage adapter was used after it was closed.
TRANSACTION_ACTIVEA storage adapter was closed from inside one of its own transactions.
TRANSACTION_CLOSEDA transaction handle was used after its transaction finished.
TRANSACTION_REQUIREDA write was attempted outside a transaction.

INTERNAL_ERROR

Something failed on the server that Better IAM does not describe to the caller.

Over HTTP, any exception that is not an IamError (a bug, or one of your callbacks such as resolveResource throwing) is answered with this code and a generic message so internals never leak; direct server calls throw the original error instead. The NestJS middleware and the SCIM handlers do the same, and accessPaths.find and impact.preview use it if their read-only simulation ends abnormally.

How to fix: retry once, then find the request in your logs or http spans by its request ID and report it (observability).

HTTP 500 · thrown by @better-iam/server

Example messages:

  • Access paths did not complete
  • Impact preview did not complete

INVALID_HIERARCHY

The tenant hierarchy does not allow this parent and child, or the stored hierarchy is broken.

tenants.create and tenants.reparent refuse a child type that the parent's type does not allow in hierarchy.types, and a move under the tenant's own subtree. With status 500 it means a stored ancestry has a cycle or exceeds the maximum depth, which points to data changed outside Better IAM.

How to fix: choose a permitted parent or adjust hierarchy in your configuration; for the 500 case, repair the tenants' parentId values (tenants and identities).

HTTP 400, 500 · thrown by @better-iam/server

Example messages:

  • Child type is not permitted
  • Child type is not permitted under the new parent
  • Cannot move a tenant under its own subtree
  • Invalid tenant hierarchy

SCHEMA_VERSION

The database was created by an incompatible version of Better IAM.

The SQL adapters record a schema version and refuse to run against a database whose version they do not support.

How to fix: run the Better IAM version that created the database, or restore a backup made for this version (database operations).

HTTP 500 · thrown by @better-iam/core

Example messages:

  • Unsupported database schema version

STORAGE_CORRUPT

A stored record could not be decoded.

The adapters refuse rows whose JSON or escaped values are invalid, or whose columns disagree with the record's own id, tenant, or key, which points to data changed outside Better IAM or damaged storage.

How to fix: restore the affected data from a backup and find what wrote to the IAM tables directly (database operations).

HTTP 500 · thrown by @better-iam/core

Example messages:

  • Invalid escaped value
  • Invalid escaped keys
  • Invalid stored JSON
  • Record metadata mismatch

STORAGE_ERROR

A database operation failed for a reason other than a conflict or a busy database.

Adapters map driver errors to this code with a generic message, so SQL text and record data never reach responses; lost connections, missing tables, and full disks end up here.

How to fix: check the database's own logs and connectivity, and run better-iam migrate if the schema may be missing (database operations).

HTTP 500 · thrown by @better-iam/core

Example messages:

  • Database operation failed

STORE_CLOSED

The storage adapter was used after it was closed.

After close(), every read and transaction is refused; usually shutdown code closed the store while requests or jobs were still running.

How to fix: close the store only after in-flight work finishes, and create a new adapter if you need the database again.

HTTP 500 · thrown by @better-iam/adapter-libsql, @better-iam/adapter-postgres, @better-iam/adapter-sqlite

Example messages:

  • Database adapter is closed

TRANSACTION_ACTIVE

A storage adapter was closed from inside one of its own transactions.

How to fix: close the store only after the transaction's callback has returned, typically at process shutdown.

HTTP 500 · thrown by @better-iam/adapter-libsql, @better-iam/adapter-postgres, @better-iam/adapter-sqlite

Example messages:

  • Cannot close an adapter inside a transaction

TRANSACTION_CLOSED

A transaction handle was used after its transaction finished.

The tx store passed to a transaction callback is valid only until the callback settles; keeping it for later, or not awaiting work started inside the callback, leads here.

How to fix: await every storage call inside the callback, and never keep tx beyond it.

HTTP 500 · thrown by @better-iam/adapter-libsql, @better-iam/adapter-postgres, @better-iam/adapter-sqlite

Example messages:

  • Transaction has already completed

TRANSACTION_REQUIRED

A write was attempted outside a transaction.

Every insert, put, and delete must run inside store.transaction(), so writes are serialized and related changes commit together.

How to fix: wrap the writes in store.transaction(async (tx) => { ... }) and write through tx (adapter contract).

HTTP 500 · thrown by @better-iam/adapter-libsql, @better-iam/adapter-postgres, @better-iam/adapter-sqlite

Example messages:

  • Database writes require transaction()

501 HTTP 501

CodeMeaning
NO_AUDIT_ARCHIVEAudit archiving was requested, but no archive destination is configured.
UNSUPPORTEDThe storage adapter cannot list its collections, which a snapshot or copy needs.

NO_AUDIT_ARCHIVE

Audit archiving was requested, but no archive destination is configured.

archiveAudit and the audit-archive command need the auditArchive option, for example createJsonlAuditArchive({ directory }).

How to fix: configure an archive sink (continuous audit archiving).

HTTP 501 · thrown by @better-iam/server

Example messages:

  • Configure auditArchive (for example createJsonlAuditArchive) to archive audit events

UNSUPPORTED

The storage adapter cannot list its collections, which a snapshot or copy needs.

store-export, store-copy, and exportStore and copyStore in code discover what to copy with the store's collections() method, which a custom adapter may not implement.

How to fix: implement collections() in the adapter, or pass the collections to copy explicitly to exportStore or copyStore (optional adapter methods).

HTTP 501 · thrown by @better-iam/core

Example messages:

  • This store cannot list its collections; pass the collections to copy explicitly
  • This adapter cannot list its collections

502 HTTP 502

CodeMeaning
OAUTH_DISCOVERYThe Microsoft Entra ID metadata for a sign-in connection could not be fetched or is not trustworthy.
WEBHOOK_REJECTEDA webhook endpoint answered a delivery with a non-success HTTP status.

OAUTH_DISCOVERY

The Microsoft Entra ID metadata for a sign-in connection could not be fetched or is not trustworthy.

Before a Microsoft sign-in, Better IAM downloads the directory's OpenID configuration and checks that the issuer and every endpoint belong to the expected authority; a network failure or unexpected metadata stops the sign-in.

How to fix: retry later if Microsoft was unreachable; otherwise check the connection's directory settings (Microsoft Entra ID).

HTTP 502 · thrown by @better-iam/oauth

Example messages:

  • Microsoft discovery failed.
  • Unexpected Microsoft issuer.
  • Microsoft metadata is incomplete.
  • Microsoft endpoints must use the authority.

WEBHOOK_REJECTED

A webhook endpoint answered a delivery with a non-success HTTP status.

Deliveries run from the outbox, so this code never reaches the call that caused the event: it is recorded as the delivery's last error, and the delivery is retried with growing delays until its attempts run out.

How to fix: make the endpoint return a 2xx status, read the recorded error with webhooks.listDeliveries, and use webhooks.redeliver once it is fixed (retries).

HTTP 502 · thrown by @better-iam/server

Example messages:

  • Webhook endpoint responded with status …

503 HTTP 503

CodeMeaning
PASSWORD_CHECK_UNAVAILABLEThe breached-password service could not be reached, and the deployment refuses passwords it cannot check.
STORAGE_BUSYThe database was too busy to complete the operation in time.

PASSWORD_CHECK_UNAVAILABLE

The breached-password service could not be reached, and the deployment refuses passwords it cannot check.

pwnedPasswords({ failClosed: true }) rejects a new password when the breach lookup fails or times out; without failClosed, the password is accepted instead.

How to fix: retry shortly; if it persists, check the server's outbound network access (password screening).

HTTP 503 · thrown by @better-iam/auth

Example messages:

  • Password screening is unavailable

STORAGE_BUSY

The database was too busy to complete the operation in time.

Lock timeouts, serialization failures, deadlocks, cancelled statements, and busy or locked SQLite files are reported with this code, and the whole transaction is rolled back.

How to fix: retry the complete operation after a short pause; if it happens often, raise lockTimeoutMs (PostgreSQL) or busyTimeoutMs (SQLite, libSQL), or shorten long transactions such as large imports (storage adapters).

HTTP 503 · thrown by @better-iam/core

Example messages:

  • Database is busy; retry the complete operation

Was this page helpful?

Last updated on

On this page

Handling errors in a UIINVALID_ARGUMENTINVALID_COMMAND400 Bad requestAUDIT_ARCHIVE_FAILEDAUDIT_ARCHIVE_INVALIDAUDIT_CHAIN_BROKENBREACHED_PASSWORDCONFIG_DRIFTCONFIG_EXISTSCONFIG_WARNINGSCREDENTIAL_CHAINING_DISABLEDDELIVERY_REQUIREDDOCTOR_FINDINGSDOMAIN_NOT_ALLOWEDFINDINGSHOSTNAME_NOT_ALLOWEDINVALID_ACTIONINVALID_CONFIGINVALID_CREDENTIALINVALID_FILTERINVALID_POLICYINVALID_RECORDINVALID_REQUESTINVALID_RESOURCE_TYPEINVALID_SEALED_VALUEINVALID_SPONSORINVALID_TICKETINVARIANTS_BROKENINVITATION_INVALIDLINKING_DISABLEDMAX_DEPTHMISSING_ENVNOT_INITIALIZEDOAUTH_CALLBACKOAUTH_STATEPASSWORD_REUSEDPROTECTED_IDENTITYRECONCILE_ATTENTIONRESOURCE_RESOLVER_REQUIREDROLE_CHAINING_DISABLEDROLLBACKSNAPSHOT_INVALIDSNAPSHOT_TRUNCATEDUNREADABLE_SECRETSVERIFIED_EMAIL_REQUIREDWEAK_PASSWORDWEAK_TRUST_CONDITIONS401 UnauthenticatedDELEGATION_TOKEN_INVALIDINVALID_ASSERTIONINVALID_CHALLENGEINVALID_CREDENTIALSINVALID_MFAINVALID_PASSKEYINVALID_TOKENOAUTH_PROFILESAML_INVALIDSESSION_NETWORK_MISMATCHUNAUTHENTICATED403 ForbiddenACCESS_DENIEDCATALOG_LOCKEDCSRFCSRF_REJECTEDDELEGATION_INACTIVEDELEGATION_NOT_ALLOWEDEMAIL_UNVERIFIEDFEATURE_DISABLEDFORBIDDENGRANT_AUTHORITY_REQUIREDHOST_MISMATCHIMPERSONATION_RESTRICTEDINSUFFICIENT_SCOPEINVALID_LINKINVALID_SESSIONINVALID_TENANT_TREEIP_BLOCKEDIP_NOT_ALLOWEDMETHOD_NOT_ALLOWEDMFA_NOT_ENROLLEDMFA_REQUIREDOAUTH_TENANTPASSWORD_EXPIREDPROTECTED_OPERATIONPROTECTED_RESOURCERECENT_AUTH_REQUIREDRESOURCE_MISMATCHSELF_REVIEWTENANT_INACTIVETENANT_MISMATCHTENANT_UNAVAILABLEUNTRUSTED_ORIGINWEB_IDENTITY_REJECTED404 Not foundNOT_FOUNDOAUTH_CLIENTTENANT_NOT_FOUND409 ConflictACCOUNT_LINK_CONFLICTACCOUNT_LINK_REQUIREDALREADY_INITIALIZEDARCHIVE_CONFLICTBILLING_PERIOD_CLOSEDCONFLICTDATABASE_IN_USEDELEGATION_EXISTSDELEGATION_PENDINGDOMAIN_TAKENFEATURE_LOCKEDHOSTNAME_TAKENIDENTITY_EXISTSINVALID_IDENTITYINVALID_TRANSITIONINVARIANT_VIOLATIONLAST_AUTHENTICATORLAST_OWNERLAST_ROOT_ADMINLIMIT_EXCEEDEDMETER_ARCHIVEDMFA_ALREADY_ENABLEDPASSKEY_EXISTSPHONE_EXISTSRESOURCE_IN_USESAME_DATABASESLUG_TAKENSOD_CONFLICTSTORE_NOT_EMPTYTEAM_MANAGEDTRANSACTION_ABORTEDVERSION_CONFLICT429 Rate limitedRATE_LIMITEDTOO_MANY_REQUESTS402 HTTP 402SPEND_LIMIT_REACHED413 HTTP 413INVALID_INPUTPAYLOAD_TOO_LARGE421 HTTP 421WRONG_REGION500 HTTP 500INTERNAL_ERRORINVALID_HIERARCHYSCHEMA_VERSIONSTORAGE_CORRUPTSTORAGE_ERRORSTORE_CLOSEDTRANSACTION_ACTIVETRANSACTION_CLOSEDTRANSACTION_REQUIRED501 HTTP 501NO_AUDIT_ARCHIVEUNSUPPORTED502 HTTP 502OAUTH_DISCOVERYWEBHOOK_REJECTED503 HTTP 503PASSWORD_CHECK_UNAVAILABLESTORAGE_BUSY