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.
| Code | Meaning |
|---|---|
AUDIT_ARCHIVE_FAILED | The audit-archive command could not archive the audit events of one or more tenants. |
AUDIT_ARCHIVE_INVALID | The audit-verify-archive command found that a tenant's archived audit chain does not verify. |
AUDIT_CHAIN_BROKEN | The audit-verify command found that a tenant's audit chain does not verify. |
BREACHED_PASSWORD | The new password appears in a known data breach. |
CONFIG_DRIFT | config-plan --fail-on-drift found differences between the tenant and the configuration file. |
CONFIG_EXISTS | better-iam init found an existing configuration file and left it untouched. |
CONFIG_WARNINGS | … unresolved reference(s) |
CREDENTIAL_CHAINING_DISABLED | A session token cannot be minted from a credential that is itself temporary. |
DELIVERY_REQUIRED | The operation sends email, but the deployment has no email delivery callback. |
DOCTOR_FINDINGS | doctor --strict found problems more serious than informational notes. |
DOMAIN_NOT_ALLOWED | The domain belongs to a shared mailbox provider, so no organization can claim it. |
FINDINGS | analyze --fail-on found access findings at or above the chosen severity. |
HOSTNAME_NOT_ALLOWED | The hostname belongs to the deployment itself, so no organization can claim it. |
INVALID_ACTION | The action name is not in the catalog, or a tenant-defined action is malformed. |
INVALID_CONFIG | The server, a storage adapter, or the CLI was given an invalid configuration. |
INVALID_CREDENTIAL | The credential id passed to a credentials method does not refer to an API key. |
INVALID_FILTER | A storage query was given an invalid filter, ordering, or pagination. |
INVALID_POLICY | A policy document or access-package rule does not follow the policy grammar. |
INVALID_RECORD | A record written to storage is not plain JSON or breaks a storage limit. |
INVALID_REQUEST | A policy evaluation input or an HTTP request target could not be understood. |
INVALID_RESOURCE_TYPE | The resource type is unknown, reserved, or cannot be used this way. |
INVALID_SEALED_VALUE | A stored secret could not be decrypted with the configured secrets. |
INVALID_SPONSOR | The AI agent's sponsor is missing or is not an active person of the agent's tenant. |
INVALID_TICKET | The inference ticket given to inference.record is unknown, already used, expired, or its caller no longer exists. |
INVARIANTS_BROKEN | check-invariants --fail-on-broken found invariants that are broken or cannot be evaluated. |
INVITATION_INVALID | The invitation link is invalid, expired, already used, or revoked. |
LINKING_DISABLED | Account linking is turned off on this deployment. |
MAX_DEPTH | Creating or moving this tenant would make the hierarchy deeper than allowed. |
MISSING_ENV | A CLI command needs an environment variable that is not set. |
NOT_INITIALIZED | Root recovery was attempted before the platform was bootstrapped. |
OAUTH_CALLBACK | The OAuth sign-in callback arrived at a URL that does not match the connection's redirect URI. |
OAUTH_STATE | The OAuth sign-in could not be matched to the browser that started it. |
PASSWORD_REUSED | The new password matches one of the person's recent passwords. |
PROTECTED_IDENTITY | A root administrator cannot be linked during organization onboarding. |
RECONCILE_ATTENTION | reconcile --fail-on-attention found access-package rules that need attention. |
RESOURCE_RESOLVER_REQUIRED | An authorization check names an application resource type, but no resolveResource callback is configured. |
ROLE_CHAINING_DISABLED | A role cannot be assumed from a session that is itself an assumed role. |
ROLLBACK | A deliberate signal inside the storage conformance suite that discards a test transaction. |
SNAPSHOT_INVALID | The file given to store-import is not a valid Better IAM snapshot. |
SNAPSHOT_TRUNCATED | The snapshot ended early, or its record counts do not match its trailer. |
UNREADABLE_SECRETS | rotate-secrets found stored values that none of the configured secrets can open. |
VERIFIED_EMAIL_REQUIRED | A first sign-in through an external provider did not come with a verified email address. |
WEAK_PASSWORD | The new password does not meet the password rules. |
WEAK_TRUST_CONDITIONS | A 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).
HTTP400 · 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).
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).
HTTP400 · 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).
HTTP400 · 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).
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.
400 · thrown by @better-iam/cli
Example messages:
- Configuration already exists; it was not overwritten
CONFIG_WARNINGS
HTTP400 · 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).
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.
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).
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.
HTTP400 · 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).
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.
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).
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).
HTTP400 · 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.
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).
HTTP400 · 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).
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).
HTTP400 · 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.
HTTP400 · 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).
HTTP400 · 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).
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).
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.
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).
HTTP400 · 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.
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.
HTTP400 · 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.
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.
HTTP400 · 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).
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).
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.
HTTP400 · 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).
HTTP400 · 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.
HTTP400 · 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).
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).
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).
HTTP400 · 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).
HTTP400 · 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).
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).
HTTP400 · 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).
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).
HTTP400 · 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).
HTTP400 · 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).
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.
| Code | Meaning |
|---|---|
DELEGATION_TOKEN_INVALID | A delegation token did not verify, or the delegation behind it has ended. |
INVALID_ASSERTION | A stateless assertion failed verification. |
INVALID_CHALLENGE | A one-time link, code, or sign-in step is wrong, expired, or already used. |
INVALID_CREDENTIALS | The email and password, or the current password, are wrong, or the account cannot sign in. |
INVALID_MFA | The authenticator code or recovery code is wrong, expired, or already used. |
INVALID_PASSKEY | The passkey response could not be verified or does not belong to this account. |
INVALID_TOKEN | The OAuth access token, or its DPoP proof, is missing, invalid, or expired. |
OAUTH_PROFILE | The identity provider's answer did not identify the person. |
SAML_INVALID | The SAML response could not be accepted. |
SESSION_NETWORK_MISMATCH | The session is bound to the network it was signed in from and was used from a different one. |
UNAUTHENTICATED | The 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.
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).
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.
HTTP401 · 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.
HTTP401 · 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).
HTTP401 · 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).
HTTP401 · 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).
HTTP401 · 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).
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).
HTTP400, 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).
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).
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.
| Code | Meaning |
|---|---|
ACCESS_DENIED | The caller is signed in but is not allowed to perform this action. |
CATALOG_LOCKED | Organizations cannot register their own resource types or actions on this deployment. |
CSRF | A protocol endpoint that acts on a signed-in session was called without a trusted Origin or the X-Better-IAM header. |
CSRF_REJECTED | The request did not carry the headers that prove it came from your application rather than another site. |
DELEGATION_INACTIVE | The delegation can no longer be used to open delegated sessions. |
DELEGATION_NOT_ALLOWED | The AI agent does not accept delegation. |
EMAIL_UNVERIFIED | The account's email address is not verified, and this deployment requires verification before sign-in. |
FEATURE_DISABLED | The feature this call needs is turned off or not configured on this deployment or for this organization. |
FORBIDDEN | Self-registration was attempted in the root tenant, where only administrators create accounts. |
GRANT_AUTHORITY_REQUIRED | The caller has permission for the action but holds no active grant authority to issue access under. |
HOST_MISMATCH | The request arrived on one organization's sign-in address but names or authenticates as another organization. |
IMPERSONATION_RESTRICTED | The operation is not available while an administrator is impersonating a member. |
INSUFFICIENT_SCOPE | The OAuth access token is valid but lacks a scope the resource requires. |
INVALID_LINK | The identities cannot be linked, or the link or linking proof is no longer valid. |
INVALID_SESSION | OAuth consent was attempted with a credential that is not a person's signed-in session. |
INVALID_TENANT_TREE | The organization's chain of parent tenants contains a cycle, so authentication refuses it. |
IP_BLOCKED | Requests from this network are blocked for the organization or for the whole platform. |
IP_NOT_ALLOWED | The organization only allows sign-in from certain networks, and this address is not one of them. |
METHOD_NOT_ALLOWED | The organization does not permit this sign-in method, or the HTTP method is not supported on this route. |
MFA_NOT_ENROLLED | The operation needs a second factor, but the person has not enrolled one. |
MFA_REQUIRED | The session has not completed multi-factor authentication, and this action or organization requires it. |
OAUTH_TENANT | The person's Microsoft directory is not allowed to sign in through this connection. |
PASSWORD_EXPIRED | The password is correct but older than the organization's maximum password age. |
PROTECTED_OPERATION | An OAuth client was about to be stored outside the authenticated registration path. |
PROTECTED_RESOURCE | The target is a protected owner role or policy, or belongs to a higher grant authority, and cannot be changed this way. |
RECENT_AUTH_REQUIRED | The operation is sensitive, and the session was not authenticated recently enough. |
RESOURCE_MISMATCH | Your resolveResource callback returned nothing, or a resource that does not match the one being authorized. |
SELF_REVIEW | A reviewer tried to certify their own access in an access review. |
TENANT_INACTIVE | The organization, or one of its parent tenants, is suspended, pending, or deleted. |
TENANT_MISMATCH | The signed-in person belongs to a different organization than the OAuth client asking for consent. |
TENANT_UNAVAILABLE | The organization is not active, so nobody can sign in to it. |
UNTRUSTED_ORIGIN | The request came from a browser origin that the deployment does not trust. |
WEB_IDENTITY_REJECTED | The 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.
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).
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).
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).
HTTP403 · 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.
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.
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).
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.
HTTP400, 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.
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.
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).
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).
HTTP403 · 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).
HTTP403 · thrown by @better-iam/oauth
Example messages:
- Missing scope: ….
INVALID_LINK
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.
HTTP400, 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).
HTTP403 · 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.
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).
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).
HTTP403 · 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.
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).
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).
HTTP403 · 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.
HTTP403 · 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).
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).
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.
HTTP400, 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).
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).
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).
HTTP403 · 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.
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).
HTTP403 · 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.
HTTP403 · 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.
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.
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.
| Code | Meaning |
|---|---|
NOT_FOUND | The record does not exist in this tenant. |
OAUTH_CLIENT | The OAuth client is unknown, revoked, or belongs to an organization that is not active. |
TENANT_NOT_FOUND | An 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.
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).
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.
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.
| Code | Meaning |
|---|---|
ACCOUNT_LINK_CONFLICT | The external account you are linking is already linked to a different account in this tenant. |
ACCOUNT_LINK_REQUIRED | 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. |
ALREADY_INITIALIZED | Bootstrap was run on a database that already has a root tenant. |
ARCHIVE_CONFLICT | The audit archive already holds a file for this batch of events with different contents. |
BILLING_PERIOD_CLOSED | The month has already been invoiced for this billing account, so its usage and prices can no longer change. |
CONFLICT | The request conflicts with a record that already exists or with the record's current state. |
DATABASE_IN_USE | Code running inside a transaction tried to use the same SQLite or libSQL database through a second adapter instance. |
DELEGATION_EXISTS | An AI agent and a person already have a pending request or an active delegation between them. |
DELEGATION_PENDING | The person has not approved this delegation yet. |
DOMAIN_TAKEN | Another organization has already verified this email 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. |
HOSTNAME_TAKEN | Another organization has already verified this custom hostname. |
IDENTITY_EXISTS | An identity with this email address already exists in the tenant. |
INVALID_IDENTITY | The identity involved is not in a state that allows this operation. |
INVALID_TRANSITION | The record is not in a state that allows this step. |
INVARIANT_VIOLATION | The change would break an enforced access invariant, so it was rolled back. |
LAST_AUTHENTICATOR | Deleting this passkey would leave the account with no way to sign in. |
LAST_OWNER | The operation would leave the organization without an active owner. |
LAST_ROOT_ADMIN | The operation would leave the platform without an active root administrator. |
LIMIT_EXCEEDED | The tenant has reached a limit on how many of these records it may have. |
METER_ARCHIVED | The billing meter is archived and accepts no new usage. |
MFA_ALREADY_ENABLED | The person already has an authenticator app enrolled. |
PASSKEY_EXISTS | This passkey is already registered. |
PHONE_EXISTS | Another person in this organization has already verified this phone number. |
RESOURCE_IN_USE | The record is still referenced by other records, so it cannot be deleted or changed this way. |
SAME_DATABASE | The source and target of a store copy are the same database. |
SLUG_TAKEN | Another organization already uses this slug. |
SOD_CONFLICT | The change would give one person a combination of roles that a separation-of-duties rule forbids. |
STORE_NOT_EMPTY | The target database of an import or copy already holds records. |
TEAM_MANAGED | The group belongs to a team, and its members come from the team. |
TRANSACTION_ABORTED | A database operation failed inside a transaction, so the whole transaction was rolled back. |
VERSION_CONFLICT | The record changed since you read it, so your change or acceptance was refused. |
ACCOUNT_LINK_CONFLICT
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).
HTTP409 · thrown by @better-iam/server
Example messages:
- External identity is already linked to another account
ACCOUNT_LINK_REQUIRED
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).
HTTP409 · 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).
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).
HTTP409 · 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).
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.
HTTP409 · 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.
HTTP409 · 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).
HTTP409 · 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.
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.
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.
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.
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.
HTTP409 · 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.
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.
HTTP400, 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).
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.
HTTP409 · 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.
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.
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.
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).
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).
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.
HTTP409 · 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.
HTTP409 · 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.
HTTP400, 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).
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.
HTTP409 · 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).
HTTP409 · 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).
HTTP409 · 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.
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).
HTTP409 · 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.
HTTP409 · 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.
| Code | Meaning |
|---|---|
RATE_LIMITED | There were too many attempts in a short time, so the caller must wait before trying again. |
TOO_MANY_REQUESTS | The 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).
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.
429 · thrown by @better-iam/server
Example messages:
- Resolve pending requests before opening more
402 HTTP 402
| Code | Meaning |
|---|---|
SPEND_LIMIT_REACHED | An 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).
402 · thrown by @better-iam/server
Example messages:
- The budget “…” is spent
413 HTTP 413
| Code | Meaning |
|---|---|
INVALID_INPUT | A field in the request is missing, has the wrong type, or is out of range. |
PAYLOAD_TOO_LARGE | The 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.
HTTP400, 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.
HTTP413 · thrown by @better-iam/scim, @better-iam/server
Example messages:
- Request body is too large.
- Request body too large
421 HTTP 421
| Code | Meaning |
|---|---|
WRONG_REGION | The 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.
421 · thrown by @better-iam/server
500 HTTP 500
| Code | Meaning |
|---|---|
INTERNAL_ERROR | Something failed on the server that Better IAM does not describe to the caller. |
INVALID_HIERARCHY | The tenant hierarchy does not allow this parent and child, or the stored hierarchy is broken. |
SCHEMA_VERSION | The database was created by an incompatible version of Better IAM. |
STORAGE_CORRUPT | A stored record could not be decoded. |
STORAGE_ERROR | A database operation failed for a reason other than a conflict or a busy database. |
STORE_CLOSED | The storage adapter was used after it was closed. |
TRANSACTION_ACTIVE | A storage adapter was closed from inside one of its own transactions. |
TRANSACTION_CLOSED | A transaction handle was used after its transaction finished. |
TRANSACTION_REQUIRED | A 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).
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).
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).
HTTP500 · 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).
HTTP500 · 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).
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.
HTTP500 · 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.
HTTP500 · 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.
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).
500 · thrown by @better-iam/adapter-libsql, @better-iam/adapter-postgres, @better-iam/adapter-sqlite
Example messages:
- Database writes require transaction()
501 HTTP 501
| Code | Meaning |
|---|---|
NO_AUDIT_ARCHIVE | Audit archiving was requested, but no archive destination is configured. |
UNSUPPORTED | The 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).
HTTP501 · 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).
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
| Code | Meaning |
|---|---|
OAUTH_DISCOVERY | The Microsoft Entra ID metadata for a sign-in connection could not be fetched or is not trustworthy. |
WEBHOOK_REJECTED | A 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).
HTTP502 · 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).
502 · thrown by @better-iam/server
Example messages:
- Webhook endpoint responded with status …
503 HTTP 503
| Code | Meaning |
|---|---|
PASSWORD_CHECK_UNAVAILABLE | The breached-password service could not be reached, and the deployment refuses passwords it cannot check. |
STORAGE_BUSY | The 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).
HTTP503 · 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).
503 · thrown by @better-iam/core
Example messages:
- Database is busy; retry the complete operation
Was this page helpful?
Last updated on