Changelog
Every notable change to the Better IAM packages, newest first.
Unreleased
-
Organization sign-in addresses, custom hostnames, and regions. Guide
docs/hosts-and-regions.md(site: Deployment → Sign-in addresses and regions).hosts.patternsgives every organization with an alias its own address, like an AWS account sign-in URL:'{tenant}.signin.example.com', with the region if you like ('{tenant}.signin.{region}.example.com'), and'{tenant}.localhost:3000'in development. Requests on an organization's address are pinned to it: public sign-in calls act in it (tenantIdmay be left out), and another organization'stenantId, session, API key, or page is refused withHOST_MISMATCH(403). Organization origins are trusted like the deployment's own; cookies stay host-only.nodeHandlernow keeps the request'sHostwhen addresses are configured, andhosts.forwardedHostreadsX-Forwarded-Hostbehind your own proxy.- Custom hostnames (
hosts.customHostnames): newhostnamesAPI group (add,list,verify,setPrimary,delete; actionsiam:hostnames:*; collectionstenantHostnames,hostnameOwners) verifieslogin.acme.comwith a DNS TXT record (throughdomains.resolveTxt) and returns the CNAME to publish (hosts.cnameTarget). New codesHOSTNAME_TAKEN(409) andHOSTNAME_NOT_ALLOWED. Passkey ceremonies on a hostname outside the RP ID answerFEATURE_DISABLED. - Regions (
regions: { current, regions, locate? }):Tenant.region(inherited from ancestors; organizations under the root default to the creating region),tenants.create({ region }), root-onlytenants.setRegion(audittenant:region).tenants.lookup,domains.discover, public sign-in calls, and organization addresses answerWRONG_REGION(421) withregionandlocation(the sign-in URL in the home region) everywhere else;IamClientErrorgainsregionandlocation.regions.locate(alias)redirects aliases that live in another region's database. tenants.lookup({ host }), andregion/signInUrlintenants.lookupanddomains.discoverresults.iam.hosts(region,resolve,signInUrl,allowedfor on-demand TLS checks). Every email and SMS message carriessignInUrl, whichrenderDeliveryMessagepasses to link builders; link builders now also receive the tenant ID for sign-in emails whose payload lacks it.- The Next.js and Express/Hono/Fastify/SvelteKit integrations keep the visitor's host on in-process calls, so server actions and loaders are pinned too.
-
Developer experience: the CLI is rebuilt on a declarative command registry, and everything it does is available from code. Guide
docs/cli.md.- Every existing command keeps its flags, error codes, and results. New for all of them:
--flag=value,better-iam help <command>/<command> --helpwith each flag's default and environment variable, "did you mean" suggestions,help --json(a machine-readable manifest, alsocliManifest()),--format json|compact|tableand--query PATH,CODE: messageplus aHint:line on failure, and exit status 2 for command-line mistakes (1 otherwise). - Configuration discovery:
--config, thenBETTER_IAM_CONFIG, then the nearestbetter-iam.config.{mjs,js,ts,mts,cjs}in the working directory or a parent, thenBETTER_IAM_DATABASE_URL+BETTER_IAM_SECRETwith no file (configFromEnv). Factories receive{ command, env, cwd }. A configuration module may exportcli = { defaults }(flag defaults per command or'*') andcommands(project commands built withdefineCommand, which get the same parsing, help, output, and completion).init --typescriptwritesbetter-iam.config.ts; the template is a side-effect-freedefineConfigfactory. - Token commands (
config-*,analyze,report,mine-roles,check-invariants,whoami, and the new ones below) run in process or against a running server with--url/BETTER_IAM_URL, and read--tenantfromBETTER_IAM_TENANT. - New commands:
api GROUP.METHODcalls any HTTP API route (HTTPie-stylekey=value,key:=json,key:=@file.json, nested keys,--data @file|-;api --listshows every route and whether it needs a credential);login(password fromBETTER_IAM_PASSWORDor a hidden prompt, authenticator or--email-codeMFA,--with-tokenfor API keys) saves sessions as profiles in an owner-only credentials file, withlogout,profiles [use|remove], andtoken;can,explain, andwho-cananswer authorization questions;config-validatechecks tenant configuration offline (--strictfails on names the file does not define);secretprints a new deployment secret;completion bash|zsh|fish|powershell. - Configuration as code:
config-plan/config-apply/config-validateaccept.mjs/.js/.tsmodules whose default export is the configuration or a factory of{ tenantId, env }, andconfig-export --output x.ts|x.mjswrites a typed module. - Code:
@better-iam/serverexportsdefineConfig,configOptions, anddefineTenantConfig;@better-iam/cliexportsrunCli,createCli,defineCommand,main,runBinary,loadConfig,configFromEnv,findConfigFile,lintTenantConfig,loadTenantConfig,localTransport/remoteTransport,listRoutes,createProfileStore,formatResult, andselectPath.runCli'siogains optionalerr,prompt,stdin,cwd, andfetch. The umbrellabetter-iampackage now installs thebetter-iambinary. - Changed: results that some commands printed on one line (
purge,outbox,audit-prune,audit-export,config-export --output) are now indented like the others; use--format compactfor one line.
- Every existing command keeps its flags, error codes, and results. New for all of them:
-
Feature flags at platform and tenant level. Guide
docs/feature-flags.md.- New
featuresAPI group:create,update,delete,list,setTarget,listTargets,setOverride, andevaluate. Catalog actionsiam:features:read,iam:features:manage, andiam:features:overrideact oniam/features/{key}. Audit actionsfeature:create,feature:update,feature:delete,feature:target, andfeature:override. New errorFEATURE_LOCKED. - Flags on the root tenant are platform flags. Any other tenant's flags reach its own subtree, and a key belongs to the tenant closest to the root, so tenants cannot shadow platform flags.
- Resolution order: kill switch, then the closest target or tenant override (a tenant's override beats a target on the same tenant, and a
lockedtarget silences overrides beneath it), then a stable percentage rollout per branch, then the default. Targets can lapse (expiresAt), andinternalflags stay hidden from tenants. iam.features(evaluate,values,isEnabled) for server code. React hooksuseFeatureFlagsanduseFeatureFlag.rolloutBucketis exported.- Decisions expose the flags that are on as the
tenant.featurescontext key, read only when a condition names it and stripped fromresolveContext/plugin context.policies.testfills it in, and policy lint knows it. - Console: an Administration "Feature flags" page and an organization "Features" page.
- Collections
featureFlagsandfeatureTargetsare removed with their tenant on purge.
- New
-
Temporary credentials (STS): AssumeRole parity, session tokens, signed session JWTs, and OIDC web-identity federation. Guide
docs/temporary-credentials.md.roles.assumetakessessionName,sourceIdentity,tags,formatandaudience, and accepts API keys and session tokens as sources. The source's scopes or session policy bound only theiam:roles:assumedecision (re-checked on every use), not the role session, which acts with the role's permissions within the trustceilingand its ownpolicy. Durations are capped by the newsts.maxRoleSessionSeconds(default 3600, up to 43200) and a per-trustmaxSessionSeconds; a duration outside the range isINVALID_INPUT.role:assumedis recorded in the target tenant.- Identity trusts gain
maxSessionSeconds,passSourceAttributes,allowedTagKeys,sourceIdentityModeanddescription; new root-onlytrust.update. Newanalysisfindingtrust-passes-foreign-attributes. sts.getSessionToken(iam:session-tokens:create): abiam_sts_token for the caller's own identity from a user session or API key, bounded by an optionalpolicyand the source's scopes, with an optional TOTPmfaCodestep-up (auth:mfa:step-up) for the MFA-then-assume pattern; capped bysts.maxSessionTokenSecondsandsts.maxSessionTokensPerIdentity. New errorCREDENTIAL_CHAINING_DISABLED.sts.getCallerIdentityfor every credential kind (CLIbetter-iam whoami), androles.listSessionswith allowlist projections that never carry hashes, policies or authority ids.- Revoke older sessions:
roles.revokeSessions,trust.revokeSessionsandoidcProviders.revokeSessions(iam:roles:revoke-sessions) move asessionsRevokedBeforewatermark and delete the matching rows;trust.revokenow deletes the trust's live sessions.identities.revokeSessions({ keepApiKeys: true })ends everything but the API keys.oidcProviders.updatealso ends every session issued through the provider (watermark plus deletion, for good) when it changesjwks,jwksUri,algorithms,audiences,maxTokenLifetimeSecondsorclockToleranceSeconds, or disables the provider; a rename, areplayProtectionchange or enabling keeps them. Rotating staticjwkstherefore makes workloads exchange again. - Session JWTs (
format: 'jwt', EdDSA/ES256) with the newsts.jwtoption,GET {basePath}/.well-known/jwks.json,iam.sessionTokens(jwks(),verify()), and the runtime-neutralcreateSessionTokenVerifier(@better-iam/server/session-tokens, umbrellabetter-iam/session-tokens) withverifyRequest. IAM accepts a JWT bearer only with a matching stored session row. - Web identity (
sts.webIdentity, off by default): tenant-managedoidcProviders,trust.create({ kind: 'web-identity' })with claimconditionsovertoken.<claim>keys (atoken.subpin is mandatory, elseWEAK_TRUST_CONDITIONS), the publicsts.assumeRoleWithWebIdentityexchange (uniformWEB_IDENTITY_REJECTED, per-trust rate limit, single-use replay protection, SSRF-guarded key fetch), and thetrust.evaluateWebIdentitydry run. - Session-aware policy keys:
principal.sessionId,tokenIssueTime,authTime,mfaTime,sessionTagKeys,sessionTags.<key>,sourceTenantId,sessionName,sourceIdentity,webIdentityProvider,webIdentitySubject, andrequest.sourceIp; audit events and webhook bodies carrysessionContext. New audit actionsrole:assumed,role:assumed-with-web-identity,session-token:issued,role:sessions-revoked,auth:mfa:step-up; new catalog actionsiam:trust:update,iam:roles:revoke-sessions,iam:session-tokens:create,iam:oidc-providers:*.@better-iam/servernow depends onjose. - Integrations: the client accepts JWTs and prefixed tokens,
@better-iam/nextprincipals add kindsession-tokenwithsessionName/sourceIdentity, the NestJS guard's CSRF check matches the bearer scheme case-insensitively, and the console labels the new kinds. - Migration: new tokens are prefixed and checksummed (
biam_ses_,biam_key_,biam_rol_,biam_sts_, 58 characters;credentialTokenScanPatternfor scanners). Legacy unprefixed tokens still work until they expire. Tooling that assumed 43-character tokens needs updating. - Migration:
auth.requireRecentnow refuses temporary credentials (role sessions and session tokens) withRECENT_AUTH_REQUIRED. Role sessions previously passed for five minutes after their source signed in; perform those operations from a signed-in session. - Migration: sign-in results no longer carry
tokenHash:SessionResult.sessionis nowSafeSessionfor every flow, in process and over HTTP. - Migration: new cross-tenant identity trusts default to
passSourceAttributes: false, so source attributes no longer reach their role sessions unless enabled. Existing trusts keep passing them; turn it off withtrust.update(the new finding lists them).trust.createandtrust.listreturn the public projection (requiresExternalId, neverexternalIdHash), and theroles.assumeresponse is a superset of the previous{ token, session }. - Migration:
credentials.revokeonly accepts API keys (INVALID_CREDENTIALfor any other session id). - Migration: the new server-owned context keys (and every existing
principal.*key,request.time,request.sourceIpandprincipal.sessionTags.*) now strip same-named values supplied byresolveContextor plugins, even when the server leaves the key absent. The new session names are reserved: identity attributes namedsessionId,tokenIssueTime,authTime,mfaTime,sourceTenantId,sessionName,sourceIdentity,sessionTags,sessionTagKeys,webIdentityProviderorwebIdentitySubjectfail withINVALID_CONFIG.
-
React Router: new package
@better-iam/react-router(umbrellabetter-iam/react-router) for framework mode, v7.9+ and v8.createIamRouter(iam, options)gives:middlewarefor the root route: per-request helpers in the router context, with cookies from the in-process client put on the responseapifor anapi/iam/*resource routeguard(loader, spec): login and step-up redirects (.data-awarenext), and a 403data({ code, message })for error boundaries ordeniedRedirectaction(fn, spec): an Origin check first, then IAM refusals returned asdata({ code, message }, { status })helpers(args),requireSession,require, andsessionData
Guide
docs/react-router.md, runnableexamples/react-router(react-router-serve, typegen +tsc, smoke test), teststests/react-router.test.ts. -
Express, Hono, and Fastify: new package
@better-iam/middleware(umbrellabetter-iam/express,better-iam/hono,better-iam/fastify, andbetter-iam/middleware).createIamExpress/createIamHono/createIamFastifydo four things:- serve the IAM API (Express and Fastify through
iam.nodeHandler, so node-only protocol mounts listed inservework too; Express also accepts bodies a parser already consumed and restores a stripped mount path) - attach per-request helpers (
req.iam,c.get('iam'),request.iam): memoizedgetSession,requireSession,require, batchedcan,authorize,listAccessible,assertion,credential,signOut, and an in-process typedclientwhose cookies land on the response - provide
requireSession({ stepUp })/authorize(action, { resource, tenantId, stepUp })route guards, which also refuse cookie-authenticated unsafe requests from untrusted origins (UNTRUSTED_ORIGIN/CSRF_REJECTED;trustedOrigins,csrf: false, exportedcheckRequestOrigin) - map refusals to the IAM JSON envelope, or to
loginPath/stepUpPathredirects for page navigations (errorHandler/onError)
The framework-neutral core (
createRequestHelpers,enforceGuard,refusalResponse,checkStepUp,IamRequestError) now also backs@better-iam/svelte/kit. Guidedocs/node-frameworks.md, teststests/middleware.test.ts(real Express and Fastify servers, a Hono app). - serve the IAM API (Express and Fastify through
-
SvelteKit and Svelte: new package
@better-iam/svelte(umbrellabetter-iam/svelteandbetter-iam/svelte/kit).createIamKit(iam, options)gives SvelteKit apps:- a
handlehook that serves the IAM API and enforcesprotectpath rules (login redirect with?next=, step-up page,authorizewithdeniedRedirector a 403 carrying the IAM code) - per-request
event.locals.iam:getSession,requireSession,require, batchedcan,authorize,listAccessible,assertion,credential,signOut, and an in-process typedclientwhoseSet-Cookieanswers land inevent.cookies, so sign-in forms work without JavaScript guard(load, spec)for server loads,action(fn, spec)for form actions (IAM refusals becomefail(status, { code, message })),sessionData(event),safeRedirectPath, andcheckStepUp
The browser entry
createIam({ client, initialSession })gives Svelte 4/5 stores:session,authorize,can, andaccessible. Inputs can be stores, andinitialvalues come from server loads, so hydration needs no second request. It also exportssetIamContext/getIamContext. Guidedocs/sveltekit.md, runnableexamples/sveltekit(adapter-node, smoke test,svelte-check), teststests/svelte.test.tsandtests/svelte-kit.test.ts. - a
-
Storage queries run in SQL:
find()filters become typed JSON conditions evaluated by the database, with pagination in SQL when the whole filter is expressible. Hot lookup fields (INDEXED_FIELDS: session token hashes, identity/group/role ids, email, OAuth artifact hashes, delivery queues) are indexed: partial expression indexes on SQLite/libSQL, onejsonb_path_opsGIN index on PostgreSQL, and id-ordered tenant and natural-key indexes. A session lookup among 20,000 sessions on SQLite fell from ~26 ms to ~0.25 ms. The SQLite adapter caches prepared statements. Schema steps are recorded by name iniam_migrations(new migration0002_query_indexes). Results are ordered by id in code-point order on every adapter. Identifiers (collection, id, tenant, natural key) with an unpaired surrogate are refused (INVALID_RECORD) instead of silently aliasing the U+FFFD spelling; reads and deletes treat them as absent. Field values still accept any string; the PostgreSQL adapter stores U+0000 and unpaired surrogates in a reversible jsonb-safe encoding (encodeJsonbDocument/decodeJsonbDocument), and migration0002rewrites existing rows that need it. SQLite and libSQL compare numbers by their JSON text, so integers above 2^53 and extreme exponents match exactly. PostgreSQL migrations wait up to ten minutes for another instance's migration. New exports:RecordDriver.query,planQuery,MAX_QUERY_CONDITIONS,JSONB_ESCAPE_KEY,sqliteSelect,postgresSelect,applyMigrations,schemaMigrations,matchesFilter,compareIds,storableString, and the adapter conformance suite@better-iam/core/conformance(adapterConformanceCases,runAdapterConformance), now run against SQLite, libSQL, and PostgreSQL. -
Storage operations:
- Store snapshots move a deployment between databases and adapters, for example SQLite to PostgreSQL. CLI
store-export --output FILEwrites a consistent JSON Lines snapshot with a header and a counting trailer.store-import --input FILEloads it into an empty database in one transaction and rolls back on a truncated, corrupt, or refused snapshot.store-copy --target-config FILEcopies directly. Copying a database onto itself fails withSAME_DATABASE. Core exportsexportStore,importStore, andcopyStore. - SQLite and libSQL: a second adapter instance of the same kind on the same file, used inside the first one's transaction, now fails with
DATABASE_IN_USEinstead of waiting forever for its own caller's lock. Paths are compared after resolving links, Windows short names, andfile://URLs. A SQLite and a libSQL adapter on one file still wait for the busy timeout and fail withSTORAGE_BUSY. - Retention sweep:
iam.sweepExpired({ limit, batchSize, deliveryRetentionMs, graceMs })and CLIsweep [--limit N] [--retention-days N]delete, in short batches, records that nothing uses any more and that until now accumulated forever. API keys and other session kinds, invitations, access requests, usage records, and pending deliveries are kept. Migration0004_expiry_indexesindexesexpiresAt,deliveredAt, andfailedAtacross each collection; the sweep walks these indexes once per run and steps over records it keeps. Shared Signals deliveries now recordfailedAt, and dispatched audit hooks recorddeliveredAt(tests/retention.test.ts). Swept:- user and role sessions, trusted devices, and relationship tuples past expiry;
- OAuth artifacts and login states (grants 31 days after expiry), and SAML request, relay, and replay records;
- delivered or abandoned outbox messages and abandoned Shared Signals deliveries past the retention;
- dispatched audit hook rows.
find()accepts an id cursor (after) for keyset pagination, served by the primary-key and tenant indexes; snapshots page with it, so exporting a large audit log does not slow down page by page.- Migration
0005_lookup_indexesindexessourceSessionIdandtrustId(SQLite and libSQL). Lookup indexes are now listed per schema step (LOOKUP_INDEX_STEPS), so released steps never change;INDEXED_FIELDSis their union. - Continuous audit archiving: the
auditArchive: { write(batch), batchSize }option andiam.archiveAudit()(CLIaudit-archive [--tenant ID] [--limit N]) copy each tenant's audit chain to independent storage.- Batches are verified and in chain order, and a per-tenant cursor records what has been archived.
- A per-tenant lease lets one run at a time archive a tenant (
busylists the others). createJsonlAuditArchive({ directory })is a crash-safe, write-once file sink (ARCHIVE_CONFLICTfor a different batch under an existing name). CLIaudit-verify-archive --directory DIR --tenant IDverifies an archive without the database.- Broken chains (
AUDIT_CHAIN_BROKEN), sink failures, and gaps are reported rather than skipped. - Once a tenant has an archive cursor,
pruneAuditdeletes only events the archive holds, in every process, and reportsheldForArchive;doctorreportsaudit-archive-behind(tests/audit-archive.test.ts). better-iam initconfigurations readBETTER_IAM_PREVIOUS_SECRETS.
- Deployment secret rotation: the new
previousSecretsoption keeps authenticator secrets, webhook secrets, pending deliveries, emailed links, and assertions sealed or signed under an old secret working. New values usesecret.iam.rotateSecrets()(CLIrotate-secrets [--dry-run]) re-seals stored values with the current secret.iam.assertionKeys()lets downstream services accept both keys during the switch.verifyAssertion,IamAssertionModule.forRoot, and the Next.jsverifyAssertionTokenall accept a key list. The run reportscompleteanddone, anddoctornever calls a partial sample done (secret-rotation-unverified). The deployment guide describes a staged rollout that is safe with several instances. The outbound SCIM provisioner takespreviousEncryptionKeysand offersrotateKeys(), for keys an application derives from the secret.doctorreports values still pending rotation, and values no configured secret opens (the secret was replaced outright). Auth exportsopenSecret, anddecryptSecretaccepts a list of secrets (tests/secret-rotation.test.ts). - Deployment self-check:
iam.selfCheck()reports findings with a severity and a fix: schema behind, not bootstrapped, SQLite durability that can corrupt, in-memory or asynchronous-commit storage, a placeholder secret, a short metrics token, no email transport, and scheduled jobs that are not running (sweep backlog, lapsed recordspurgeshould have removed, stalled or abandoned outbox messages, stalled audit hooks).doctorprints them, anddoctor --strictexits non-zero on any error or warning (tests/self-check.test.ts). doctoraddsstoragefrom the new optionalIamStore.describe(): schema version, applied migrations, record counts per collection, and adapter settings. A database that cannot be reached failsdescribe()instead of reporting as empty.- Ordered reads: optional
IamStore.findOrderedand thefindOrderedhelper page by a numeric field in SQL (migration0003_ordered_indexes). AuditlistandexportandpruneAudituse it, so their cost follows the page size instead of the log length. OptionalIamStore.collections()lists collections. - SQLite file databases default to WAL with
synchronous = FULL;journalMode: 'delete'anddurability: 'normal'opt out. - Session validation writes
lastSeenAtat most once a minute, or once per tenth of the idle timeout when that is shorter. - Authorization reads a subject's bindings with indexed lookups per group instead of scanning the tenant's bindings.
instrumentStoreandsummarizeStoreCallsreport storage calls without values;pnpm bench:scalemeasures hot paths at a configurable deployment size.- Tests:
tests/store-snapshot.test.ts,tests/store-describe.test.ts, andtests/retention.test.ts.
- Store snapshots move a deployment between databases and adapters, for example SQLite to PostgreSQL. CLI
-
Security: an assumed-role session whose source account owned another tenant passed the target tenant's owner gates (
identities.setOwner, owner offboarding) and satisfiedprincipal.owner/principal.rootAdminpolicy conditions there. Those gates now require an owner of the target tenant on an ordinary session, and role sessions reportprincipal.ownerandprincipal.rootAdminas false (tests/owner-gates.test.ts). -
auth.getSessionandauth.listSessionsno longer return a session'suniqueKey, which held its token hash;SafeSessionomits it. -
Role mining:
roleMining.suggest(iam:analysis:read) finds direct bindings a group already covers, roles every member of a group holds directly, roles with identical grants, and role bundles many people hold together (closed itemsets, skipped when a package or inheriting role already matches);roleMining.apply(iam:analysis:update) carries out the binding suggestions in one transaction under the original grant authority;roleMining.outliersreports access few peers (same manager or identity attribute) hold and access most peers hold that a person lacks. CLImine-roles, console Role mining page. -
Access usage tracking: the
accessUsageoption records which actions each identity was allowed to use (authorization checks and provisioning operations, not root overrides or impersonation), buffered in memory and written in batches to the newaccessUsage/accessUsageTrackingcollections (iam.flushAccessUsage()writes immediately).roleMining.usagelists it androleMining.rightSizereports bindings whose holders used none or only some of a role's actions in a window, plus per role the actions nobody used. Console: a Least privilege card on the Role mining page (the console enables tracking). -
Console redesign: grouped, collapsible sidebar sections with a "Jump to…" page filter; a Governance hub per organization (urgency-ranked "Needs attention" list and tiles for findings, invariants, separation of duties, unused access, role mining, certifications, approvals, and terms of use); a "Governance at a glance" card on the overview; and an Organization health page in the administration panel that ranks every organization by findings and broken invariants.
-
Review fixes for the governance features: the usage recorder's timer and early writes no longer inherit the async context of the transaction that recorded first (which broke periodic writes and could abort
authorize), failed writes back off;listAccessiblecounts as usage and root overrides do not; enforced invariants also guard resource deletion, root grants, campaign closes, and agreement publishing, evaluate every subject, and refuse changes that make them unevaluable; role mining no longer treats package-owned memberships as cover or suggests group bindings for groups with inactive members; sign-in evidence (recommendations and the dormant-access finding) ignores "view as" sessions;accessPaths.findmasks deny reasons;impact.previewis refused while impersonating. -
Review recommendations:
roleMining.reviewRecommendationssuggests keep or revoke for each access-certification item from account status, recorded usage (when it covers the window), or the last sign-in, with the reason; the console's campaign page shows them. -
Change impact preview:
impact.preview(iam:policies:simulate) applies a candidate role update, policy document, or role deletion with the real validation and edit rights inside an always-rolled-back transaction and reports, per holder of the affected roles (including inheritors and group members) and per resource, the actions gained and lost. Console: a Change impact page. -
Access invariants:
invariants.create/update/delete/list/run(built-in actionsiam:invariants:read|manage, collectionaccessInvariants) state who may, or must never, perform an action on a resource (one identity, a group, everyone with an attribute value, or everyone).runevaluates them;enforcemode re-checks them around every access-changing operation and refuses a change that newly breaks one withINVARIANT_VIOLATION, while pre-existing violations do not block unrelated work.impact.previewreports invariants a candidate change would break or fix. Console: an Access invariants page. CLIcheck-invariants --tenant ID [--fail-on-broken]. Scheduler jobiam.checkInvariants()/ CLImonitor-invariantsrecordsinvariant:brokenandinvariant:restoredaudit events once per status change (lastCheckon each invariant). -
Terms of use:
agreements.create/update/delete/list/status(built-in actionsiam:agreements:read|manage, collectionsagreements/agreementAcceptances) publish versioned agreements with optional periodic re-acceptance; members callagreements.listMine/acceptwithout a permission (auditedagreement:accept). Policies see the new context keysprincipal.agreementsandprincipal.pendingAgreements(known to the linter and reserved from identity attributes), so a deny statement can require acceptance. Console: a Terms of use page and an acceptance banner for members. -
Self-service access paths:
accessPaths.find({ tenantId, action, resource })tells a denied person what they could do themselves — step up to MFA, accept pending terms of use, activate one of their eligible bindings, or request a requestable package — each verified by simulating it in a rolled-back transaction; needs only an ordinary session. -
@better-iam/reactand@better-iam/vue:useAgreements(pending terms of use andaccept) anduseAccessPaths(self-service ways to be allowed). -
Configuration as code covers
invariants(subjects by group name, member email, attribute, or everyone) andagreements(a content change publishes a new version); both kinds are exported only when present, so existing documents are unchanged. -
Birthright access packages:
autoAssignrules (policy-condition include/exclude clauses over identity attributes, kind, owner flag, email, email domain and verification, manager, and direct group memberships) give a package to every matching identity and take it away when they stop matching, with an optional grace period. Rules run under their owner's grant authority and rights, re-checked on every run (suspended, never widened, when the owner leaves or loses rights). They reconcile after identity changes and rule saves, throughpackages.reconcile, and through the new scheduler jobiam.reconcilePackages()/better-iam reconcile; brakes hold back unusually large unattended changes until confirmed. Also:packages.previewAutoAssign,listAssignments({ source }),automaticon assignments,packages.assigntaking over automatic assignments, configuration documents carryingautoAssign(with group names), the console rule editor and preview, the new collectionpackageRuleIssues, andpackage:auto-*audit events. -
@better-iam/nextauth forms, step-up, service credentials, and background work:- Auth forms.
iamNext.authActions(options)(andcreateAuthActions) returns drop-in server actions:signIn(password or emailed sign-in code, then authenticator, emailed, or recovery codes, first-time enrollment with one-time recovery codes, 'keep me signed in' and 'remember this device'),reauthenticate(step-up that ends the session it replaces),signOut(clears a stale cookie too, through the newclearSessionCookie()),requestPasswordReset,resetPassword,signUp,verifyEmail, andacceptInvitation. The organization is resolved fromtenantId, anorgslug,resolveTenant, or the email's verified domain. They return a serializableAuthFormStatethat never holds passwords, codes, tokens, or sessions.@better-iam/next/clientadds matching unstyled, accessible forms that work without JavaScript (SignInForm,ReauthenticateForm,PasswordResetRequestForm,PasswordResetForm,SignUpForm,InvitationForm, each with a*Viewtwin). - Step-up.
stepUp: { mfa: true | 'fresh', maxAgeMs }onpage,route,action,requireSession,pages.withSession, andpages.apiredirects pages tostepUpPath?next=&reason=and answers handlers and actions withMFA_REQUIRED,RECENT_AUTH_REQUIRED, orIMPERSONATION_RESTRICTED(403).checkStepUpis exported, and page errors carry aBETTER_IAM_STEP_UP:digest forerror.tsx. - Service credentials.
apiRoute()accepts API keys and assumed roles throughiam.authenticateand passes a whitelistedIamPrincipal. - Background work.
dispatchAfterResponseschedules outbox and event dispatch withafter()after every in-process call and every POST to the mounts.iamNext.backgroundoffersdispatch(),schedule(), andcron(), a route for schedulers: bearerCRON_SECRET, fails closed, and runs isolated tasks (purge, audit retention, digest, reminders, outbox, events). - Hardening.
route(),apiRoute(), andpages.api()refuse cookie-authenticated cross-origin mutations (CSRF_REJECTED/UNTRUSTED_ORIGIN; newtrustedOriginsoption) and map onlyIamError/IamClientErrorto responses.safeRedirectPathno longer lets dot segments (/.//evil.example) normalize into a protocol-relative redirect.sessionForClient()andpages.withSessionprops drop the session'suniqueKey. Sessions refused for network or tenant-tree reasons read as signed out. The umbrellabetter-iam/next/clientre-exports by name, because Next rejectsexport *in a'use client'module. - Example.
examples/nextjsuses all of this: the login, step-up, and password-reset pages, a development inbox,apiRoute, and a cron route.
- Auth forms.
-
SCIM directory sync: the enterprise extension's
managerbecomesIdentity.managerId(mapManager, on by default; by SCIM ID,externalId, oruserName, back-filled when the manager arrives later, loops and self-references skipped, administrator-set managers never cleared). SCIM can no longer reactivate or regroup an identity an administrator deleted.createScimServiceserves a JSON administration API for consoles at{adminBasePath}/connections/{list,create,rotate,revoke,groups,mappings}(default/scim/admin; CSRF header, Origin check, 64 KiB bodies) and gainslistGroups. Console: a Directory sync page (connections with their SCIM endpoints, one-time tokens, rotation, revocation, and group-to-role mappings), inbound SCIM mounted at/api/iam/scim/v2, and the console's IAM route now forwardsPUT,PATCH, andDELETE. -
Policy linter:
analysis.lintPolicy(and the exportedlintPolicy) checks a candidate or stored document against the catalog and reports statements that are valid but probably wrong: unknown context keys such asrequest.ip, denies on optional keys that silently never fire, negated conditions over unresolvable variables, arrays compared with string operators, type mismatches, shadowed allows, duplicates, and wildcard administration. Its shadowing check is budgeted against adversarial documents. Console: a Lint card with a draft checker on each policy page. -
Access analysis gains
standing-privileged-access,unused-eligible-binding,orphaned-manager,manager-cycle, andpolicy-lintfindings. -
Manager-review certifications:
reviewerMode: 'manager'routes each person's items to their manager, who decides them withcertifications.reviewwithout holding a certification permission;certifications.listMine,certifications.remind(certification-reminderemails), andautoClosewith the deployment jobiam.closeOverdueCertifications/ CLIclose-certifications.renderDeliveryMessagerenderscertification-reviewandcertification-reminderwith alinks.certificationbutton. Console: "Assigned to you" review queue, reviewer column, "Remind reviewers", and manager-mode, due-date, and auto-close fields. -
Separation of duties also covers access-package assignment and approved package requests.
-
Access-package and access-lifecycle hardening from a multi-agent review:
packages.extendnow needs the rightsassignneeds, plus a grant authority, whenever it lengthens an assignment or removes its end. It moves the package's bindings to the extender's authority. Shortening still needs onlyiam:packages:assign.- Request and activation decisions are refused from impersonation sessions (
IMPERSONATION_RESTRICTED). packages.listApprovalslists only requests the caller holdsiam:packages:approveon.- Requests that name approvers but would reach none are refused.
- A request lapses no later than the end it asks for.
listRequestsreports lapsed requests asexpired. - Tightening a package cancels pending requests that no longer fit it. A direct
assignmarks the pending request approved. - Package bindings are the assignment's own (their own uniqueness key), so they never replace or depend on hand-made grants.
- Packages that share a group hand its membership to each other.
- Hand edits (
bindings.update,groups.updateMember, re-adding a lapsed member) take a package's record over. - Assignments whose bindings no longer grant are reported
brokenand can be assigned or requested again. - Offboarding revokes package assignments under package ownership.
groups.deleterefuses an approver group still in use.- Offboarding hands reports to a successor without creating self-management or cycles. A manager must be active, and tombstones drop their
managerId. - The access report ignores disabled expired identities, lapsed memberships, and activations that can no longer grant. The purge worker removes the activations of purged bindings and disabled identities.
- Expiry-reminder dedupe is kept in a new
expiryReminderMarkscollection, so it survives audit pruning. - Configuration documents validate package and binding durations, treating
nullas no cap, and match package names case-insensitively, so a case-only difference is a rename. bindings.updateaccepts amanagerApproval-only change.- CLI
remind --within-daysis limited to 1–365. - The console member page keeps name and attributes, manager, and deactivation in separate forms.
-
Shared Signals Framework transmitter:
createSharedSignalsTransmitter(better-iam/oauth) turns IAM activity into signed Security Event Tokens (RFC 8417) and pushes them (RFC 8935) to each tenant's receivers. It covers CAEPsession-revoked(sign-outs and administrator or tenant-wide revocations) andcredential-change(password, authenticator, passkey), and RISCidentifier-changed(email),account-disabled(offboarding, expiry), andaccount-purged. Subjects areiss_suboremail, events carryinitiating_entityand the audit event astxn. Streams are managed by tenant administrators (createStream/listStreams/getStream/updateStream/deleteStream, built-in actionsiam:ssf:streams:*on the new internal resource typessf; audited) with encrypted receiver credentials, event and subject filters, pause and resume,verifyStreamverification events, delivery history, retries with backoff (dispatch), and/.well-known/ssf-configurationmetadata. New collectionsssfStreamsandssfDeliveries. -
MCP-ready OAuth: dynamic client registration (RFC 7591) at the discovered
registration_endpointwhencreateOAuthProvider({ registration })is set. Registrations present a tenant-scoped token fromcreateRegistrationToken(listRegistrationTokensandrevokeRegistrationTokenmanage them; tokens are stored hashed with a use limit) or pass the host'sregistration.anonymoushook. New clients are bound to that tenant, public with PKCE unlessallowConfidential, limited to the authorization code and refresh grants, HTTPS or loopback or reverse-domain redirect URIs, and the allowed scopes and resources. Nothing the provider would fetch can be registered. They are audited and listed withregisteredVia. New collectionoauthRegistrationTokens. RFC 9728 protected resource metadata (protectedResourceMetadata,protectedResourceMetadataUrl,createProtectedResourceHandler), and the verifier'schallenge(error, realm, { resourceMetadata, scopes })points clients at it.createResourceGuardcombines metadata serving, token verification, and RFC 6750 challenges into onecheck(request)for MCP servers and other APIs. Plain OAuth 2.1 authorization codes (noopenid) now receive a refresh token when the client is registered for the refresh grant, since MCP hosts send neitheroffline_accessnorprompt=consent. OpenID requests are unchanged.tests/oauth-mcp-integration.test.tsruns the whole chain against a real server: challenge, discovery, registration, consent, the guarded call, and revocation on sign-out. -
Console email verification:
/cloud/verify-emailcompletes averify-emaillink on a click, the account page shows an unverified address with a "Resend verification email" button, and the deliveries page opens verification links in development. -
Security notices link home:
renderDeliveryMessageaccepts the message'stenantIdand alinks.account({ tenantId })builder;new-sign-inandsign-in-failuresemails then carry a "Review your account" button (text and HTML). The console's deliveries page points it at the organization's account page. -
Cookie options:
http.cookieSameSite(laxby default, orstrict) andhttp.persistentCookies(trueby default). A request that issues a session may sendX-Better-IAM-Persistent: 0(or1) to receive a browser-session cookie that disappears when the browser closes, or a lasting one, regardless of the default; the server session keeps its own lifetime either way. Preflight responses now also allowX-Request-IdandX-Better-IAM-Persistent. Console: a "Keep me signed in on this browser" checkbox on the login page, carried through the second-factor step. -
Discoverable passkey sign-in:
auth.beginPasskeyAuthentication({ tenantId })without an email issues options that name no credential, so the authenticator offers its own passkeys for the relying party, andfinishPasskeyAuthenticationfinds the account from the presented credential (user handle and ownership are still checked; disabled accounts are refused). Such anonymous starts are limited per address at ten times the ordinary allowance. Console: the login page offers passkeys in the email field through browser autofill as soon as it loads, and "Sign in with a passkey" no longer needs an email. -
Client options:
onUnauthenticated(error)runs once per request the server refused asUNAUTHENTICATED(a lapsed or revoked session; never a wrong password), so an application redirects to its login page in one place;retryRateLimited(trueor{ maxWaitMs }, off by default) retries aRATE_LIMITEDcall once after the server'sretryAfterMswhen that wait is short enough;requestId(trueor a function) sends anX-Request-Idon every request, which the server echoes and records on its spans, andIamClientError.requestIdcarries it for support tickets. -
Named passkeys:
finishPasskeyRegistrationaccepts aname(at most 64 characters; the default names the device kind from the authenticator's transports),listPasskeysreturnsname,createdAt,lastUsedAt(stamped by passkey sign-ins and passkey MFA),deviceType,backedUp,aaguid, andtransportsnewest first, andauth.renamePasskey({ id, name })relabels one (audited asauth:passkey:rename). Console: the account page names each passkey with "synced" or "this device only", when it was added and last used, an inline rename, and a name suggestion (browser and system) when adding one. -
Tenant policy
bindSessionsToIp: a user session is accepted only from the client IP it was issued from; presented from another address it is refused withSESSION_NETWORK_MISMATCH(401) and the attempt is recorded in the person's trail asauth:session:mismatchwith both addresses, so a stolen cookie is useless elsewhere and the person signs in again from the new network. Sessions and requests without a recorded address are not judged. Console: the toggle and status on organization settings. -
Network blocks:
security.blockNetwork({ tenantId, network, reason, durationMs?, platform? })(iam:security:manage, recent authentication, audited assecurity:network-block) refuses every authentication flow and live session whose recorded client IP falls in an IPv4/IPv6 address or CIDR block withIP_BLOCKED, before rate limits or credentials are examined; root administrators setplatformblocks on the root tenant for the whole installation, organizations block for themselves, adurationMs(one minute to a year) makes a block lapse,security.unblockNetworklifts it, andsecurity.listBlocks(iam:security:read) reports them withactive. The caller's own address is refused. Console: "Block for a day" per source address and a platform block list on the admin Sign-in failures page, and a "Blocked networks" card on organization settings. -
Brute-force defenses:
authentication.rateLimits.ipAttempts(off by default) caps attempts per client IP and tenant across every authentication flow, on top of the per-account limits, so credential stuffing and password spraying from one address run out no matter how many accounts they name (needs a recorded IP;identities.unlocknever clears it).authentication.failedSignInAlerts(off by default; needssendEmail) queues onesign-in-failuresemail (attempts,time,ip,userAgent; rendered byrenderDeliveryMessage) the moment a person's failed attempts since their last sign-in reach that number, once per streak. The console enables the alert at five attempts whenever it has a mail transport, and the admin panel gains a Sign-in failures page: failed attempts across organizations for the last hour, day, or week, grouped by source address and by targeted account (with the account's current streak and an unlock), plus the latest attempts. Console session, device, and activity lists now name clients ("Chrome 128 on Windows · 203.0.113.7") instead of showing raw user-agent strings. -
Idle-timeout warning:
auth.getSessionreturnslimits(lifetimeMs,idleTimeoutMs, andidleExpiresAt, honouring the tenant policy), and both console panels warn two minutes before a session lapses for inactivity (or reaches its maximum length) with a countdown, "Stay signed in", and "Sign out now", returning to the login page once it has lapsed (which then explains the inactivity or the reached lifetime via?reason=idle|expired); activity in the tab keeps the session alive without a prompt. -
Sign-in record and failed attempts: a wrong password, authenticator or emailed code, or recovery code presented for a real, active account is recorded as an
auth:signin:failaudit event (metadata.reason,ip,userAgent) in its own transaction and counted per person;auth:session:createevents carry the method and client too. Every new session from a sign-in flow carriessession.previousSignIn(previous sign-in time and client,failedAttemptssince, latest failed attempt's time and client; absent on a first sign-in) and the count restarts.auth.listSecurityEventsreturns eventmetadata. Console: a dismissable notice above every page when attempts failed since the previous sign-in, and "Previous sign-in" / "Failed attempts since" on the account page, whose security activity now names the reason and client of each event. -
docs/enterprise.mdwalks one customer organization through verified domains, SAML or Entra ID single sign-on, sign-in policy, SCIM provisioning in and out, end-to-end offboarding, and audit. The console runs a periodic outbound provisioning sync so that expiries, which emit no event, still reach connected applications. -
HTTP hygiene and account status: every JSON response carries
X-Content-Type-Options: nosniffandReferrer-Policy: no-referrerbesideCache-Control: no-store; a plainX-Request-Idis echoed on responses and recorded asrequestIdonhttpspans.auth.listSessionsmarks the calling session withcurrent, andauth.mfaStatusreports the authenticator state, unused recovery codes, passkeys, remembered devices, and whether the session passed MFA (the console account page warns when recovery codes run low). The admin panel gains a Live sessions page: every unexpired session across organizations, filtered by organization, kind, name, email, or IP, with impersonators marked and a per-person sign-out. -
Console sign-in links and codes: the login page offers "Email me a sign-in link or code" (passwordless email is enabled whenever the console server has a mail transport); a link is redeemed at
/cloud/magicon a click, a six-digit code is typed on the login page itself (startPasswordlesswithkind: 'code',finishPasswordlesswith the code, resend and back), and the sharedMfaChallengestep (authenticator, enrollment, recovery code, emailed code, passkey, remember-device) now serves password and link sign-ins alike;mfaChallengeStateprepares it from anymfaRequiredoutcome. The deliveries page opensmagic-linklinks in development.tenants.usagereportsmfaEnrolled(people with an authenticator or a passkey) and the settings usage card shows MFA adoption. -
Managers:
Identity.managerId(identities.create/update({ managerId | null }): another active identity of the tenant, never oneself or a report, so reporting lines stay acyclic),identities.listReports, and offboarding hands a manager's reports to the successor (reportsReassigned) while deleting an identity clears them.managerApprovalon an eligible binding or an access package lets the requester's manager decide on activation and package requests (alongside an approver group, if one is named) and emails them each request. The configuration document carries the flag; the console shows the manager and reports on the member page and offers the option on the assign-a-role and package forms. -
Expiry reminders and assignment extension:
iam.sendExpiryReminders({ tenantId?, withinMs? })(CLIremind, a deployment operation likedigest) emails each person whose account, direct role bindings, group memberships, or package assignments end within the window (seven days by default) oneexpiry-remindermessage listing them (itemsas JSON with kind, name, and end), once per item and end date, recorded asidentity:expiry-reminder; moving an end brings a fresh reminder.packages.extend({ packageId, identityId, expiresAt | null })moves the end of an assignment and of every binding and membership it created together (audited aspackage:extend; console card on the Access packages page). -
Self-service package requests: a
requestablepackage (optionally with anapproverGroupId) can be asked for withpackages.request({ packageId, expiresAt?, justification? })underiam:packages:request; the request waits for the tenant'sapprovalLifetimeMs, approver-group members are emailed (package-request), andpackages.approveRequest/denyRequest(iam:packages:approveon the package, group membership when one is set, never one's own) decide it, assigning under the approver's authority and emailing the requester (package-decided).packages.cancelRequest,listRequests({ packageId?, identityId?, status? }),listApprovals, andlistMine(requestable packages with the caller's status on each) complete the flow; lapsed requests are swept by the purge worker (expiredRequests), offboarding cancels pending ones, the configuration document carriesrequestable/approverGroup, and everything is audited aspackage:request,package:request-approved|denied|cancelled.packages.revokenow needs onlyiam:packages:assignon the package (the assignment owns its records, whichever authority issued them). Console: request/approve cards on the Elevate page, request settings and a requests table on the Access packages page. -
Access packages:
packages.create/update/delete/get/listbundle roles and groups (AccessPackage, actionsiam:packages:create|read|update|delete|assign).packages.assign({ packageId, identityId, expiresAt?, justification? })grants the whole bundle in one transaction as ordinary identity bindings and group memberships tagged with the assignment, skipping what the person already holds, honoring the package'smaxDurationMsandrequireJustification, and requiringiam:bindings:createon each role andiam:groups:updateon each group besidesiam:packages:assign;packages.revokeremoves exactly what the assignment added;packages.listAssignmentslists holders. Assignments end with their expiry (PurgeResult.expiredAssignments), go with offboarding and deletion, block deleting a packaged role or group or a held package (RESOURCE_IN_USE), travel with the configuration document (packagesby name), and are audited aspackage:assign/package:revoke. Console: Access packages page. -
Access digest:
iam.sendAccessDigest({ tenantId?, withinMs?, unusedForMs?, minimumIntervalMs? })(CLIdigest, a deployment operation likepurge) emails the owners of every active organization whose access report has findings anaccess-digestmessage (counts plus the full report as JSON), at most once per interval (20 hours by default) per organization, recorded astenant:access-digest.groups.addMembersadds up to 100 members in one transaction with an optional shared expiry; the console group page adds several at once. -
Temporary group memberships and future-dated bindings:
groups.addMember({ expiresAt })andgroups.updateMember({ expiresAt | null })make a membership end by itself (with every grant and activation it carried);listMembersandidentities.listGroupsreportmembershipExpiresAt, re-adding a lapsed member renews it, and the purge worker removes lapsed records (expiredMemberships). Bindings acceptstartsAt(bindings.create/update,nullclears): the grant is listed with its start but applies only from then. The access report gainsstartingbindings andexpiringMemberships. Console: "Member until" on the group page, "Starts on" / "Ends on" when assigning a role, and membership expiry on the member page. -
Tenant access policy:
tenants.setAccessPolicy({ tenantId, accessPolicy | null })(iam:tenants:update, recent authentication, audited astenant:access-policy) sets organization-wide floors for just-in-time activation:maxActivationMscaps every binding,requireJustification/requireMfa/requireApprovalapply to every eligible binding, andapprovalLifetimeMssets how long requests wait. Bindings can only be stricter. Configuration sync carriesaccessPolicy({}clears it). Console: an "Elevation defaults" card on the Configuration page. -
Access report:
reports.access({ tenantId, withinMs?, unusedForMs? })(iam:identities:read; the binding and credential sections neediam:bindings:readandiam:credentials:readand are named inomittedotherwise) returns identities and temporary bindings ending within the window, live activations, the number of pending activation requests, and API keys nobody used or that end soon. CLIreport --tenant ID [--within-days N] [--unused-days N]prints it as theBETTER_IAM_TOKENholder. Console: a Reports page with clear-deadline, make-permanent, end-activation, revoke, and rotate actions, and an activation history card on the member page. -
Approval-gated activation: eligible bindings accept
requireApprovalandapproverGroupId;bindings.activatethen records a pending request (lapsing after 24 hours) and emails the approver group (activation-request).bindings.approveActivation/bindings.denyActivation(iam:bindings:approveon the role; approver-group members or root when a group is set; never one's own request) make the role live for the requested or a shorter duration, or refuse it, and email the requester (activation-decided).bindings.listApprovalslists the requests a person may decide on,listActivationsacceptsstatus,listMinereportspendingActivation, and requesters cancel withdeactivate. Audited asbinding:activation-requested|approved|denied. Configuration sync carriesrequireApprovalandapproverGroupby name. Console: the Elevate page shows pending requests, lets approvers decide, and the member page sets the approval rules. -
credentials.create({ scopes })restricts an API key to an action allowlist (compiled to a session policy;CredentialSummary.scopesreports it), andbindings.list({ expiresBefore })reports temporary bindings ending before a time. Console: a scopes field when issuing keys. -
Tenant policy
requireMfaForOwnersrequires a second factor from owners only (their non-MFA sessions stop at the next use; they enroll on the next sign-in). Console: the toggle in settings, a "Change email" card on the account page (auth.requestEmailChange), and/cloud/confirm-emailfor the confirmation link; the deliveries page opensemail-changelinks in development. -
Microsoft Entra ID sign-in:
createOAuthLoginconnections acceptkind: 'microsoft'withmicrosoftTenant(organizationsby default,common,consumers, or one tenant) and an optional sovereign-cloudissuer. Multi-tenant connections requireallowedMicrosoftTenants(Entratidvalues), and each ID token is validated against the concrete issuer of its own directory. Emails count as verified only with Entra'sxms_edovclaim (protection against "nOAuth" email spoofing).begin(connectionId, credential, { loginHint, domainHint, prompt })and thelogin_hint/domain_hint/promptquery parameters of the start route forward validated sign-in hints (Microsoftdomain_hint, Googlehd, GitHublogin). -
Delivery templates and back-off:
renderDeliveryMessagefrom@better-iam/auth/templates(alsobetter-iam/auth/templates, a subpath without native dependencies; re-exported from the main auth entrypoint) renders every built-in outbox template (verify-email,password-reset,email-change,magic-link,code,mfa-code,new-sign-in,owner-invitation,member-invitation) into{ subject, text, html }with your own link builders.RATE_LIMITEDerrors carryretryAfterMs; HTTP responses addRetry-After, andIamClientError.retryAfterMsexposes it. The console's deliveries page shows each message's rendered subject. -
Role inheritance:
roles.create/roles.updateacceptinherits(role IDs; at most 20, no cycles, no protected roles, empty list clears); a role grants its own policies plus, recursively, the grants of the roles it inherits, each bounded by the inheriting role's authority ceilings as well as the inherited role's. Deleting an inherited role is refused withRESOURCE_IN_USE. Configuration sync exports and appliesinheritsby name. Console: an Inherits card on the role page. -
Access windows: bindings accept
window: { from, to, timeZone, days? }(bindings.create/update,nullclears); outside the recurring window the binding grants nothing.identities.listBindingsreportsinWindow; configuration sync carries windows on group bindings. Console: the window on the member page's role table and assignment form. -
Offboarding:
identities.offboard({ tenantId, identityId, reason, successorId? })(recent authentication,iam:identities:update) disables an identity and, in one transaction, revokes its sessions and keys, removes its role bindings (under the caller's authority), group memberships, activations, relationships, and pending access requests, revokes the grant authorities it holds, removes ownership likesetOwner, and reassigns the managed resources it owns to a successor or reports them. Audited asidentity:offboardwith the reason and counts. Console: an Offboard card on the member page. -
CLI
config-plan --fail-on-driftexits non-zero (CONFIG_DRIFT) after printing the plan when the tenant differs from the file, for CI checks. -
Outbound SCIM provisioning:
createScimProvisioner({ ...iam.protocolHost, encryptionKey })frombetter-iam/scimkeeps downstream SCIM 2.0 applications in step with a tenant's active members, or with members of chosen groups. It creates users (adopting existing ones byexternalIdoruserName), pushes changes, and deactivates or deletes (deprovision) people who are disabled, deleted, expired, or leave scope. Identity attributes map totitleand the enterprise extension (attributeMapping). Targets are managed throughcreateTarget,listTargets,getTarget,updateTarget, anddeleteTarget(built-in actionsiam:scim:targets:create|read|update|delete|sync; audited), with encrypted write-only bearer tokens.syncTargetruns on demand,syncAllsuits schedulers, andsubscribe(iam.events)syncs after member changes. Each run reports created/updated/deactivated/deleted/unchanged/failed counts and failure details inlastRun. New collectionsprovisioningTargets,provisioningLinks, andprovisioningGroupLinksare removed with their tenant.pushGroupsalso maintains the scoped groups downstream (display name and provisioned members; adopted, updated, or deleted as scope changes).provisioner.handlerserves the operations as a JSON API (basePath, IAM CSRF rule and envelope) foriam.useProtocol. Console: an App provisioning page connects applications, scopes them to groups, and syncs, pauses, or removes them. It shows per-run results and failures, and syncs follow member changes automatically.previewTarget(and a Preview button) reports what the next sync would change using read-only lookups only. Memberships past theirexpiresAtno longer count toward a target's scope or pushed groups, and access-package assignments (iam:packages:*,package:*events) trigger a sync. -
Passkeys as the second factor: a sign-in that returns
mfaRequirednow reportspasskeyAvailablewhen the person has a passkey registered;auth.beginPasskeyMfa({ tenantId, challenge })andauth.finishPasskeyMfa({ tenantId, challengeId, response, rememberDevice? })complete MFA with a user-verified WebAuthn assertion bound to that login challenge (both challenges are consumed; remembered devices supported). Console: passkey registration and removal on the account page, "Sign in with a passkey" on the login page, and "Use a passkey" at the MFA step (all throughbetter-iam/client/passkeys).tests/support/webauthn.tsprovides a virtual P-256 authenticator for tests. -
Tenant-managed SAML connections: with
serviceProvider(one deployment-wide SP key pair and base URL) and the host'sauthorize,createSamlServiceaddscreateConnection,listConnections,getConnection,updateConnection, anddeleteConnection(built-in actionsiam:saml:connections:create|read|update|deleteon the new internal resource typesaml; audited). Organizations enter their IdP's metadata XML, or its sign-on URL, issuer, and certificates, and get fixed{basePath}/{id}/metadata|acs|loginURLs. The declarativeattributeMappingfeeds identity attributes, summaries report certificate fingerprints and expiry for rollover, andenabled: falsestops sign-ins.parseIdpMetadata,normalizeCertificate, andcertificateInfoare exported,getMetadata(id)serves both configured and managed connections, andsamlConnectionsis removed with its tenant. IdP-initiated SAML sign-in is available per connection (allowIdpInitiated, configured or managed): responses answering no request are fully validated and each assertion ID is accepted once (samlAssertions);idpInitiated(connectionId, samlResponse)is the direct call andvalidateSamlEnvelopeacceptsnullas the expected request. -
Nuxt and Vue. New package
@better-iam/vue(alsobetter-iam/vue) for Vue 3.3+.createIam({ client })is the plugin, anduseSession,useAuthorize,useCan, anduseAccessibletake refs or getters and re-run when the input or the signed-in identity changes.<IamCan>hasdefault/fallback/loadingslots. Server rendering awaits queries throughonServerPrefetchand hands the results to the browser through anIamHydrationstore (createHydration), so hydration neither refetches nor mismatches. New package@better-iam/nuxtis a Nuxt 3.14+/4 module (betterIamconfig key). It mounts the IAM API at/api/iam/**in Nitro from the file named byinstance(default~~/server/iam), initializes the instance on first use, and loads the session during SSR through an in-process client bound to each request (event.context.betterIam). Page access comes fromdefinePageMeta({ iam: true | false | { action, resource?, tenantId?, redirectTo? } })with optionalrequireAuth: 302 tologinPath?next=when signed out, and a 403 error page on the server and on client navigation. The module auto-importsuseIamSession/useIamClient/useIamAuthorize/useIamCan/useIamAccessibleand<IamCan>(sessions typed from the registered instance) and the server utilitiesgetIamSession,requireIamSession,requireIamAccess,iamCan,issueIamAssertion,iamCredential, anduseIam.@better-iam/nuxt/h3exportscreateIamH3for any h3 v1/v2 or Nitro app (per-event session memo, h3-compatibleIamH3Errorwithdata.code, Web or Node request bodies). The framework-agnostic session store moved to@better-iam/client/session(better-iam/client/session);@better-iam/reactre-exports it unchanged, andisUnauthenticatedalso recognizes in-processIamErrors with status 401/403.examples/nuxtis a runnable Nuxt 4 app with a production smoke test, anddocs/nuxt.mdis the guide. -
Separation of duties:
sod.create/list/update/delete/violations(built-in actionsiam:sod:read|manage) declare roles nobody may hold together. Prevent rules refuse, withSOD_CONFLICT, any binding, group membership, bulk onboarding, access-request approval, configuration apply, or invitation acceptance that would create a new conflict, while pre-existing conflicts are reported instead of blocking; detect rules only report. The access analysis gains a high-severityseparation-of-dutiesfinding. Console: a Separation of duties page. -
docs/authentication.mdis a full guide to sign-in methods, MFA (authenticator, recovery codes, emailed codes, remembered devices), sessions and recent authentication, account recovery, tenant authentication policies, impersonation, HTTP cookie behaviour, and the deployment options and email templates. The console gains self-service password recovery: "Forgot your password?" on the login page,/cloud/resetto request the email and to choose a new password from its link, and the deliveries page openspassword-resetlinks in development. -
Configuration as code: the
configAPI group (iam:config:read,iam:config:apply) exports a tenant's roles, policies, groups (with member emails), tenant-defined resource types, and group role bindings as one JSON document keyed by name (config.export), computes the creates, updates, and deletes a document implies (config.plan, withprunefor items a listed kind omits), and applies it in a single transaction where every change is authorized like the direct call and one refusal rolls everything back (config.apply, audited asconfig:apply). CLI:config-export,config-plan,config-apply [--prune]as theBETTER_IAM_TOKENholder.validateTenantConfigand theTenantConfig/ConfigPlantypes are exported; the mutation helpers behindroles,policies,groups,resourceTypes, andbindingsare shared with the API. Console: a Configuration page. -
Just-in-time roles:
bindings.create/updateaccepteligible,maxActivationMs(default one hour, up to seven days),requireJustification, andrequireMfa; an eligible binding grants nothing until its subject (directly or through a group) callsbindings.activate(iam:bindings:activateoniam/{roleId}), for a bounded time, from an ordinary session, never while impersonating.bindings.deactivateends one's own activation,bindings.revokeActivationends someone else's (like deleting the binding),bindings.listActivationslists them,bindings.listMineshows members their own roles and what they may activate, andidentities.listBindingsreportsactivation. Activations end with the membership, the binding, or the role, and the purge worker sweeps expired ones (expiredActivations). Audited asbinding:activate(with the justification) andbinding:deactivate. Console: an Elevate page, eligibility on the member and role pages. -
Identity expiry:
Identity.expiresAtschedules deactivation for contractors and temporary service accounts (identities.create/createMany/update,serviceAccounts.create/update;nullclears it). Past the deadline every credential of the identity is refused (UNAUTHENTICATED),identities.list({ expiresBefore })reports upcoming expiries, and the purge worker disables such identities, revokes their sessions, and recordsidentity:expire(expiredIdentities); re-enabling requires clearing or extending the deadline first. The last owner cannot be scheduled away. Console: the deadline on the member page and when creating service accounts. -
API key hygiene: keys carry a
nameanddescription(credentials.create,credentials.update, which can also move the expiry within a year under recent authentication),credentials.getandcredentials.listreturnlastUsedAt(recorded at most once a minute when the key authenticates a request) andcredentials.list({ unusedForMs })finds keys nobody has used, including never-used ones; rotation keeps the label and starts the usage history over. Console: labeled key issuance and an "unused 30d" flag. -
@better-iam/adapter-sqlite: closing a store before its first query now releases the database file (Kysely opens the driver lazily, so the eagerly opened connection was left to garbage collection). -
Emailed MFA codes:
authentication.mfaEmailCodesand the tenant policy fieldmfaEmailCodeslet people without an authenticator satisfy an MFA requirement with a one-time code (auth.requestMfaCodefor a login challenge whose sign-in reportedemailCodeAvailable, thenverifyMfa); codes are hashed, single-use, bound to the challenge, and expire with it. Never offered to root administrators or to people with an authenticator. Console: "Email me a code instead" on the MFA step and the policy toggle in settings. The console's IAM route now answersGET, so/api/iam/healthand/api/iam/metricswork behind Next.js. -
CLI
analyze --tenant ID [--dormant-days N] [--fail-on high|medium|low]prints access-analysis findings as theBETTER_IAM_TOKENholder and exits non-zero at the chosen severity, for nightly jobs and deployment gates. -
Access certification campaigns:
certifications.create/list/get/decide/close/delete(built-in actionsiam:certifications:read|review|manage) snapshot a tenant's role bindings for designated reviewers, forbid self-review, and on close remove revoked (optionally undecided) bindings under the closer's authority, recording each item's outcome. Console: a Certifications page with per-binding keep/revoke buttons. -
Network allowlists: the tenant authentication policy gains
allowedIpRanges(IPv4/IPv6 addresses or CIDR blocks); sessions, including impersonation, are refused withIP_NOT_ALLOWEDwhen the recorded client IP is outside them, and existing sessions from outside stop working at their next use.@better-iam/coreexportsisIpRangeandipMatches. The console settings page edits the list, and the admin panel gains an Operations page (health, outbox backlog, active sessions, and the process's Prometheus counters and latency histograms; the console enablesobservability.metricsand exposesGET /api/iam/metricswhenMETRICS_TOKENis set). -
@better-iam/nextApp Router integration:iamNext.client()is the typed API bound to the current request, calling the IAM handler in process and writing the cookies it issues throughcookies(), so server actions sign people in and out (and through MFA) without client JavaScript.page(render, spec),route(handler, spec), andaction(fn, spec)wrap pages and layouts, route handlers (JSON error envelope with the server's status; cookie or bearer API key), and server actions (ActionResultforuseActionState; Next control flow propagates) with authentication and optionalauthorize: { action, resource, tenantId }. The ambientgetSession()is memoized per request with Reactcache(cacheoption), reads cookies a server action just set, andsessionForClient()returns JSON forIamProvider initialSession.interrupts: trueuses Next'sunauthorized()/forbidden();requireTenantSession({ slug })/tenant(slug)serve/[org]/...routes (unknown aliases callnotFound(), other organizations go to/login?org=);handlers()addsGETfor/healthand/metrics. Middleware gainspublicPathsglobs,signedInRedirect(bare login visits only; guards add?next=when they reject a stale cookie, so it cannot loop), andnext(passNextResponse.next) to forwardx-better-iam-pathname, which becomes the default?next=. The new edge-safe@better-iam/next/edgesubpath (better-iam/next/edge) holds the middleware plus Web CryptoverifyAssertionToken/withAssertion(offline assertion checks for downstream services),verifyWebhook/createWebhookHandler(signed webhook receiver with secret rotation, freshness, size limits, and retry-friendly failures), andsafeRedirectPathagainst open redirects. The server instance reports its HTTPendpoint(origin,basePath,secure).docs/nextjs.mdis a full guide, andexamples/nextjsis a runnable App Router application covering each helper. -
@better-iam/nextPages Router and client components:iamNext.pages.withSession(gssp, spec)(getServerSidePropswith login redirects,notFoundor redirect on denial, and the session as JSON props),pages.api(handler, spec)(API routes with the JSON error envelope),pages.client(req, res)(in-process client appending cookies to the response),pages.getSession(req), andpages.handler()forpages/api/iam/[...path].ts(accepts bodies Next already parsed).interrupts: 'forbidden'interrupts denials only and keeps login redirects. The new'use client'entry@better-iam/next/client(better-iam/next/client) addsIamNextProvider/useRouterSync()(callsrouter.refresh()when the signed-in identity changes in the browser) anduseSignOut({ redirectTo }).iamNext.allowed(action, resource?)and the async server component<iamNext.Can action resource fallback>batch every check made during a render into one deduplicatedauthorizeManyper tenant (50 per call) and reuse answers for the rest of the request. -
New package
@better-iam/nestjs(alsobetter-iam/nestjs) for NestJS 11 and 12 on Express or Fastify.IamModule.forRoot/forRootAsyncprovidesIamService(principal,authorize,require,can,listAccessible,assertion, all per request) andIamGuard. Options install the guard globally (guard), serve the IAM HTTP API from the Nest app (mount, which also handles bodies Nest has already parsed), and registerIamExceptionFiltersoIamErrors keep their status and{ error: { code, message } }body. Decorators:@Public,@Authorize(action, { resource, tenant })(rules add up across class and method; values come from params, query, body, headers, GraphQL arguments or WebSocket message fields, or functions),@RequireMfa,@Credentials('api-key'),@CurrentPrincipal/@CurrentIdentity/@CurrentSession/@TenantId, and@OnIamEvent(pattern)for provider methods (with optionaldispatchIntervalMs). The guard rejects cookie-authenticated unsafe requests from foreign origins (csrf) and supports HTTP, GraphQL and WebSocket contexts. Downstream services useIamAssertionModule.forRoot({ key, audience })withIamAssertionGuard,@AssertionClaims()and@RequireClaims({ roles, groups, mfa, kinds }). That path loads only the new@better-iam/server/assertionssubpath, so a verifying service doesn't need the server's native dependencies.@FilterAccessible(action, { type, id?, path? })trims list responses to the resources the caller may act on through thelistAccessiblereverse query, so filtering doesn't write an audit denial per item.forRootAsyncalso takesuseClass/useExisting(IamOptionsFactory).IamService.health()probes the IAM database in process, andIamService.credential(request)returns the caller's credential. The mount path defaults to the server's reportedendpoint.basePath.examples/nestjsis a runnable Express app with a smoke test (pnpm --filter @better-iam/example-nestjs smoke).@better-iam/nestjs/testing(better-iam/nestjs/testing) exportscreateTestingIam, an in-memory stand-in for tests: principals chosen by bearer token, decisions made by a callback, registered resources for@FilterAccessible, adecisionslog, andemit()for@OnIamEventhandlers. -
OAuth authorization server: client management (
listClients,getClient,updateClient,rotateClientSecret; new built-in actioniam:oauth:clients:update). Narrowing a client's grant types, scopes, or resources, or requiring DPoP, revokes everything issued to it. Connected apps:listGrants,revokeGrant,revokeGrants(self-service, oriam:oauth:grants:read|revokefor other accounts; auditediam:oauth:RevokeGrant), and repeated consent in one provider session extends a single grant. Resource servers: theresourceServersoption (RFC 8707 resource indicators) issues audience-restricted JWT access tokens (RFC 9068) to clients registered withresources. The newcreateAccessTokenVerifierverifies them offline, including DPoP proofs (RFC 9449) with replay detection. Clients can setrequireDpop/requirePushedAuthorization, the provider takesrequirePushedAuthorizationRequests/dpopNonceSecret, andinteractionDetailsaddsclientNameandresources. Confidential clients can authenticate withclient_secret_postorprivate_key_jwt(tokenEndpointAuthMethod, withjwks/jwksUri; single-use assertions, key rotation throughupdateClient). OpenID back-channel logout: clients registerbackchannelLogoutUri, andlogoutEndedSessions()notifies them and revokes consents whose IAM session ended (auditediam:oauth:SessionLogout). Outbound provider requests allow loopback targets only underallowInsecureLocalhost. Per-clientaccessTokenTtl/refreshTokenTtlcan shorten token lifetimes below the provider and resource server defaults. Clients carry consent-screen branding (logoUri,clientUri,policyUri,tosUri) and afirstPartyflag, reported byinteractionDetails(...).client. Token exchange (RFC 8693): confidential clients registered forurn:ietf:params:oauth:grant-type:token-exchangetrade an account's access token for a token to one of their resources, with the client asactand a lifetime capped by the subject token.authorizeTokenExchangeadds policy, exchanges are audited asiam:oauth:TokenExchange, and the verifier exposesactor. Consent now grants requested resource scopes, so browser flows withresourcereceive resource-server tokens. -
Access analysis:
analysis.findings(iam:analysis:read) reports unrestricted administrator policies, service-wide action wildcards, administrators without a second factor, service accounts with full administration, dormant members who still hold access, trusts without MFA, and unattached policies, unused or empty roles, and member-less groups with bindings, ordered by severity with deterministic IDs;analysis.suppress/unsuppress(iam:analysis:update) record accepted risks. Console: a Security findings page. -
Verified domains and home-realm discovery: a new
domainsAPI group (add,list,verify,delete; built-in actionsiam:domains:create|read|update|delete) lets an organization prove control of an email domain with a DNS TXT record; each domain is verified by at most one tenant and consumer mailbox providers are refused. Publicdomains.discover({ email })returns the owning tenant with its alias, accepted sign-in methods, and MFA requirement. Thedomainsoption injects the TXT resolver, record label, and blocked list. Console: a Domains page and "use your work email" on the organization picker. -
Security activity and sign-in alerts:
auth.listSecurityEventsreturns the caller's ownauth:*audit trail (impersonators named) for account pages;authentication.signInNotificationsand the tenant policy fieldnotifyNewSignInqueue anew-sign-inemail when a session starts from a client (user agent + IP) that none of the person's live sessions or remembered devices has used. Console: "Recent security activity" on the account page and the alert toggle in settings. -
Password policy: tenant authentication policies gain
passwordHistory(refuse the last 1–24 passwords;PASSWORD_REUSED),passwordMaxAgeDays(a verified but expired password is refused withPASSWORD_EXPIREDuntil reset),passwordMinClasses(2–4 character classes), andpasswordRejectPersonalInfo.authentication.passwordPolicyadds deployment-wide screening: a built-in common/sequential/low-variety screen (on by default),isBreached(BREACHED_PASSWORD;pwnedPasswords()is a k-anonymity Have I Been Pwned client), and a customcheck. Identities recordpasswordChangedAt; previous hashes live in the newpasswordHistorycollection (newest 24, removed with the identity or tenant).@better-iam/authexportspwnedPasswords,isCommonPassword, andcharacterClasses. Console settings edit the new rules. -
Trusted devices ("remember this device"):
verifyMfaandconfirmMfaacceptrememberDeviceand return adeviceToken(withdeviceExpiresAt) that lets the same browser skip MFA on latersignIn/finishPasswordlesscalls;authentication.trustedDeviceLifetimeMscaps the deployment (30 days by default, 0 disables) and the tenant policy gainstrustedDeviceDays. Root administrators are never remembered; password, email, and factor changes forget every device; sessions recordtrustedDeviceId;auth.listTrustedDevices,revokeTrustedDevice, andrevokeTrustedDevicesmanage them (audited asauth:device:trust/auth:device:revoke). The HTTP handler keeps the token in abetter-iam.devicecookie and injects it into sign-in bodies. Console: a "remember this device" checkbox at MFA, a remembered-devices card on the account page, and the policy field in settings. -
SCIM protocol coverage: RFC 7644 filters (
and/or/not, grouping, value paths such asemails[type eq "work"], sub-attributes, schema-qualified enterprise attributes,gt/ge/lt/le) evaluated against rendered resources;sortBy/sortOrder;attributes/excludedAttributesprojection;POST {Users|Groups}/.search;POST /Bulk(100 operations, per-operation transactions,bulkIdforward references,failOnErrors);If-None-Match. PATCH gains sub-attribute and value-path targets (name.givenName,emails[type eq "work"].value,urn:…:enterprise:2.0:User:department), listed-member removal and Entra-style"True"/"False"booleans, and no longer dropstitleor the enterprise extension on unrelated changes.scim.listConnectionsreports usage (lastUsedAt, user/group counts) andscim.rotateTokenreplaces a connection's token without losing provisioned state. Discovery advertises bulk, sorting and the enterprise extension schema. -
Operations endpoints:
observability.metricskeeps Prometheus-style counters and histograms from spans (iam.metrics.render()/snapshot(), exportedcreateMetrics), served atGET {basePath}/metricswhen abearerTokenis configured;GET {basePath}/healthreports database reachability. Caller-controlled names collapse and series are capped, so scrapes stay bounded;gauges: trueadds outbox-backlog and live-session gauges read from storage on each scrape. The console audit log gains a search form (action glob, actor, resource, outcome, time range) with pagination and actor names. -
Impersonation ("view as"):
identities.impersonateopens a member session for support when the tenant's authentication policy setsallowImpersonation(new built-in actioniam:identities:impersonate, recent authentication, a recorded reason, at most eight hours and never beyond the administrator's own session). Owners, root administrators, service accounts, and the caller are never eligible. Such sessions carryimpersonatorId, cannot perform recent-authentication operations, re-authenticate, assume roles, grant OAuth consent, or impersonate further, end with the administrator's session (auth.endSession), and are excluded frommaxSessions. Audit events and webhook bodies gainimpersonatorId, policies gainprincipal.impersonated/principal.impersonatorId, assertions gainimpersonatorId,AuthMethodgainsimpersonation, and the HTTP layer never sets a cookie for the impersonation token. Console: settings toggle, "View as" on the member page, a banner with a stop button, and attribution in the audit log and session lists. -
Tenant authentication policy gains
maxSessions(concurrent sessions per person; the oldest ends).identities.requestPasswordResetqueues a reset email for a member on an administrator's behalf.docs/api-reference.mdis generated from a live instance bypnpm docs:api; the HTTP route tables (routeGroups,publicApiMethods,publicAuthMethods,authenticatedAuthMethods) are exported from the server package. -
tenantDefaultsoption: plan limits and an authentication policy applied to every tenant created withtenants.create, validated at construction.tenants.resendInvitationandidentities.resendInvitationrenew an invitation's token and lifetime and queue the email again; the console offers both. -
Tenant authentication policy gains
maxAttempts(tighter rate limits for the tenant's authentication flows) andminPasswordLength(enforced on creation, reset, and change).identities.updateacceptsemailfor administrator-driven address changes (recent authentication, unverified, sessions revoked, audited asidentity:email-change). -
Federated attributes: OAuth/OIDC sign-in connections and SAML connections accept
mapAttributes; mapped values are validated againstpermissions.identityAttributesand stored on the identity at every sign-in (FederatedLogin.attributes). -
Audit retention:
iam.pruneAudit({ tenantId, retentionMs })and CLIaudit-prunedelete a tenant's oldest events behind anaudit:prunecheckpoint that keeps the chain verifiable. -
Access reviews accept platform resources (
iam/...) as the reviewed resource. -
OAuth/OIDC provider: the built-in
iamscope addsroles,groups, andattributesclaims (live when read, expired bindings excluded) to userinfo. -
apps/console: member search and pagination on the members page. -
Directory:
identities.listacceptsquery(name or email, case-insensitive),limit, andoffset, ordered by name;identities.listSessionsreturns a member's active sessions for administrators (iam:identities:read), without token material. -
docs/recipes.mdcollects copy-ready examples for sharing, reviews, policy testing, tenant auth policies, audit archives, assertions, observability, incident response, exports, limits, webhooks, bulk onboarding, and libSQL. -
Policy tooling:
policies.restoreVersionrolls a policy back to an earlier version (re-validated, kept in history) andpolicies.testevaluates a candidate document against an action, resource, and supplied context without storing it; the console policy page offers both. -
Account unlock:
identities.unlockclears the sign-in, recovery, and MFA rate-limit counters of an identity (recent authentication,iam:identities:update, audited asidentity:unlock).RateLimiter.resetis optional for custom limiters; the built-in limiters implement it. -
SCIM: users carry
titleand the enterprise extension (department,division,manager, …);mapAttributesturns them into declared identity attributes, validated throughprotocolHost.validateIdentityAttributes. -
Plan limits and usage: root sets
tenants.setLimits(members, service accounts, groups, roles, policies, resources, webhooks; audited astenant:limits); every creation path, including invitation acceptance, self-registration, federation, SCIM, and bulk creation, fails withLIMIT_EXCEEDEDpast a limit.tenants.usagereports counts, active sessions, and limits. The admin panel edits limits and both consoles show usage. -
Webhook filters: subscriptions accept
outcomesandresources(glob patterns over the resource ID) in addition to action patterns. -
Bulk onboarding:
identities.createManycreates up to 100 identities atomically with optional passwords, declared attributes, roles (authorized like invitations, bound under the caller's grant authority), and groups. -
Webhook redelivery: outbox messages remember the audit event behind a delivery (
reference;listDeliveriesexposeseventId) andwebhooks.redeliverqueues that event again, rebuilt from the audit record and signed with the current secret. The console offers it per delivery. -
@better-iam/next:iamNext.assertion({ tenantId, audience, ttlSeconds?, claims? })issues a stateless assertion for the current request's session. -
apps/console: member attributes (department, title) editable on the member page; the admin audit page reports chain integrity. -
Data-subject export and incident response:
identities.exportreturns everything a tenant stores about one identity (no secrets; audit trail when the caller may read it), audited asidentity:export;identities.revokeSessionsends one identity's sessions without disabling it;tenants.revokeSessionsends every session in a tenant (includeSelfoptional). All three require recent authentication. The console offers them on the member page and in organization settings. -
New package
@better-iam/adapter-libsql(better-iam/adapter-libsql): libSQL persistence through@libsql/clientfor local files, encrypted files, embedded replicas, and remote Turso or sqld databases, with the same schema, transaction guarantees, and error mapping as the SQLite adapter.better-iam init --database libsqlscaffolds it. -
Session metadata: sessions issued through the HTTP handler record
client.userAgent;http.clientInfo(request)derivesip,userAgent, and a devicelabelbehind a trusted proxy;iam.auth.withClient(info, fn)scopes details for direct calls.auth.revokeOtherSessionsends every other session of the caller (recent authentication required, audited asauth:session:revoke-others); the console lists devices and offers "Sign out other sessions". -
CLI:
audit-verify --tenant IDrecomputes a tenant's audit chain from storage (non-zero exit when broken) andaudit-export --tenant ID --output FILEwrites it as JSON Lines;doctorreports chained tenants and events. -
apps/console: workspace sharing through relationships (workspace page and member page), an access-reviews page (who can, effective actions), audit chain verification and JSONL export, the organization authentication policy in settings, relations on resource types, and the sign-in method on sessions. -
Observability:
observability.onSpanreceives a timed span for every provisioning operation,authorize/authorizeMany/listAccessiblequery, authentication call, and HTTP request, with tenant, outcome (ok,denied,error), error code, status, and duration. Handler failures are ignored. -
Stateless assertions (
assertionsAPI group):assertions.issue({ tenantId, audience, ttlSeconds?, claims? })returns a short-lived HS256 JWT describing the caller for a service, authorized asiam:assertions:createoniam/{audience}.iam.assertionKey()derives the verification key from the deployment secret andverifyAssertion(token, { key, audience, issuer? })checks tokens offline. -
Tenant authentication policies:
tenants.setAuthPolicysets or clearsrequireMfa,allowedMethods,sessionLifetimeMs, andsessionIdleTimeoutMsper tenant (recent authentication andiam:tenants:update; audited astenant:auth-policy). Policies only tighten the deployment's configuration; method restrictions are enforced before credentials are examined (METHOD_NOT_ALLOWED), sessions are re-validated on use, and MFA cannot be disabled while required. User sessions record their sign-inmethod, exposed to policies asprincipal.authMethod.iam.auth.mfaRequired,tenantRequiresMfa, andsessionLimitsare public for trusted integrations. -
Audit hash chain: every audit event carries
sequence,previousHash, andhash(SHA-256 over canonical JSON) linked per tenant throughauditChains; all writers (server, authentication, SCIM, OAuth provider) append throughappendAuditEvent.audit.verifychecks a tenant's chain or a window of it,audit.exportpages events as JSON Lines, andverifyAuditChain/auditEventHash/canonicalJson(core andbetter-iam) verify archives offline.initialize()backfills unchained events once. Webhook bodies includesequenceandhash. -
Access reviews under
iam:policies:simulate:policies.whoCanlists the identities that could perform an action on a resource (withkind,assumeMfa, and pagination),policies.effectiveActionslists the actions an identity holds on a resource across the catalog or a chosen list, andpolicies.simulateacceptsassumeMfa. -
Relationships (
relationshipsAPI group): resource types declarerelations(inpermissions.resourceTypes, pluginresourceTypes, andresourceTypes.register/update);relationships.create/list/deletebind identities or groups to one resource under a declared relation with optional expiry, authorized byiam:relationships:create/read/deleteoniam/{type}/{id}. Evaluation exposes the principal's live relations on the resource asresource.relationsand on its registered parent asresource.parentRelations;iam/{type}/{id}administrative checks on registered managed resources now carry that resource's attributes, owner, parent, and relations. Tuples are removed with their identity, group, or resource, and a relation still in use cannot be dropped from its type. -
Condition operators:
StringNotEquals,StringEqualsIgnoreCase,StringNotEqualsIgnoreCase,StringNotLike,StringLikeIgnoreCase,NumericNotEquals,NumericLessThanEquals,NumericGreaterThanEquals,NotIpAddress,ArrayContains, andArrayContainsAll. Negated operators andArrayContainsAllrequire every listed value; missing or wrongly typed context never satisfies any operator. -
Principal context:
principal.kind,principal.owner,principal.rootAdmin,principal.sessionKind,principal.groups, andprincipal.rolesare available to conditions and variables.permissions.identityAttributesdeclares typed attributes thatidentities.updateandserviceAccounts.updateset and policies read asprincipal.{name};Identity.attributesandIdentity.descriptionare part of the public identity. -
Plugin contract: plugins may contribute
resourceTypes,hooks.beforeOperation/afterOperation(run inside the operation transaction; throwing aborts it), andresolveContext; plugin endpoints receivedeliverto queue email/SMS through the host outbox. -
Policy variables:
${principal.id}and any other trusted context key can appear in resource patterns (after the type segment) and inStringEquals/StringLikevalues. Substituted values match literally, unresolved variables never match, and malformed references are rejected at validation. -
Reverse queries:
iam.listAccessible/client.listAccessiblereturn the registered resources of a managed type that the caller may perform an action on, evaluating grants once per query.resources.registerManyregisters up to 100 resources atomically with per-item authorization. -
Temporary bindings:
bindings.createacceptsexpiresAt; expired bindings grant nothing and are hidden from effective views (bindings.listhasincludeExpired);bindings.updateextends, shortens, or clears the expiry.purgeDeletednow also removes expired bindings and expires stale access requests, and reportsexpiredBindings/expiredRequests. -
Access requests (
accessRequestsAPI group): members withiam:access-requests:createrequest roles with a justification and optional duration; reviewers withiam:access-requests:reviewapprove (creating bindings under their own grant authority, subject toiam:bindings:createon each role) or deny; requesters cancel; pending requests expire afteraccessRequests.lifetimeMs(default 7 days). -
Events and webhooks: every audit event is now an event.
iam.events.subscribe(pattern, handler)andevents.onEventreceive committed events from the dispatcher (iam.events.dispatch, alias ofdispatchAuditHooks, now returns{ dispatched }). ThewebhooksAPI group subscribes HTTPS endpoints per tenant (root may subscribe a subtree) to event patterns; deliveries are queued in the same transaction as the audit record, signed with a per-subscription secret (X-Better-IAM-Signature, verify withverifyWebhookSignature), retried with exponential backoff, and listed throughwebhooks.listDeliveries.events.deliverWebhookreplaces the built-in HTTPS transport. -
Outbox:
dispatchOutboxreturns{ delivered, failed, abandoned }, retries with backoff (30 seconds doubling to one hour), abandons messages afterauthentication.maxDeliveryAttempts(default 25) withfailedAt/lastError, and dispatches in creation order. -
Configurable rate limits:
authentication.rateLimitssetsattempts(default 10),sensitiveAttempts(default 5),windowMs(default 15 minutes), and a pluggablelimiter;createMemoryRateLimiter()is provided for single-process deployments and tests. -
Administration fills:
identities.delete(tombstones the identity, revokes credentials, factors, bindings, memberships, authorities, and links;identities.listgainskind,status, andincludeDeletedfilters),serviceAccounts.list/get/update/setStatus/deletewith an optionaldescription,credentials.list(iam:credentials:read),trust.list(iam:trust:read),root.listAdministrators, andaudit.listfilters (actorId, globaction,resourceId,outcome,from,to) with newest-first ordering.Identity.statusgainsdeleted. -
Internal restructure with no public API change: the server is split into focused modules (
options,catalog,context,events,decisions,principals,operations,flows,lifecycle,federation,http, and one file per API group), the authentication service is assembled from feature classes over a shared base withoutboxandrate-limitmodules, SCIM separates its filter, discovery documents, provisioning, and handler, the OAuth provider adapter lives in its own module,tenantTreeActivein core replaces four private tenant-tree walkers, and the whole repository is formatted with Prettier. -
New packages:
@better-iam/react(IamProvider,useSession,useAuthorize,useAccessible,Can, and the framework-agnosticcreateSessionStore) and@better-iam/next(createIamNextwithgetSession,requireSession,require,can,handlers, pluscreateIamMiddleware), exposed asbetter-iam/reactandbetter-iam/next. -
apps/console: a Next.js administration panel (/admin, root administrators with MFA: organizations, root administrators, catalog, audit, deliveries) and multi-tenant cloud console (/cloud, alias sign-in, invitations, workspaces as managed resources, members, roles, groups, policies, resource types, resource registry, service accounts and API keys, account settings with sessions/MFA/linked accounts). Built oniam.api.*from server components, the typed client through/api/iam, andiam.requirefor enforced pages. -
Fixed
SafeIdentity/SafeSession(returned bygetSession,listSessions,groups.listMembers) losing their known properties:Omitover the record index signature erased them; they are now mapped types. -
Resource catalog:
permissions.resourceTypesdeclares application-owned and IAM-managed resource types with actions, typed attributes, and parents; managed resources are registered through the newresourcesAPI and resolved without an application callback. Tenants intenant-definedmode register their own managed types and{type}:{verb}actions through theresourceTypesandactionsAPIs. Policies, inline role documents, boundaries, ceilings, and session policies are validated against the catalog (INVALID_ACTION,INVALID_RESOURCE_TYPE). -
Breaking: tenant-defined actions no longer use the
tenant/{tenantId}/prefix;actions.listreturns{ name, source, resourceType, description }objects. Addedactions.unregister. -
Roles:
roles.create/updateacceptdescription, an inlinedocument, or apermissionslist; addedroles.get,roles.listBindings,bindings.list,bindings.readaction,identities.listBindings(effective roles through groups),identities.listGroups,identities.get,identities.update,groups.get,groups.update,groups.listMembers,policies.get,policies.listVersions, and policy/group descriptions.policies.updateaccepts name/description changes. -
Organizations as accounts: optional globally unique tenant
slugontenants.create,bootstrap, andtenants.setSlug; publictenants.lookupresolves an active tenant for alias-based sign-in. Member invitations (identities.invite,listInvitations,revokeInvitation, publicacceptInvitation) onboard people into an existing tenant with roles and groups applied under the inviter's authority.identities.createno longer requires a password.links.listreturns linked accounts for account switchers. -
iam.authorizeManyandclient.authorizeManyevaluate up to 50 checks in one transaction for UI state. Invitation responses no longer include token hashes. -
Application-level integration test (
tests/application.test.ts) exercising the HTTP handler and typed client end to end, with compile-time inference checks; the example application demonstrates aliases, invitations, permission roles, resource types, and managed workspaces. -
Tenant lifecycle administration: rename and move tenants with hierarchy, depth, cycle, and grant-authority validation; list and revoke owner invitations; delete pending tenants; retention purging through
iam.purgeDeletedand the CLIpurgecommand, with audit records preserved. -
@better-iam/projectsreference plugin: tenant-scoped project records throughprojects:read/projects:writeplugin endpoints (create, list, get, update, archive, restore), including purge cleanup of purged tenants' records.
0.1.0
Initial independent authentication and IAM implementation: tenant hierarchy, isolated identities, policies and delegation, root authority, service credentials, role assumption, password/MFA/passkey flows, federation/provisioning packages, typed client, CLI, SQL adapters, tests, examples, and synchronized packaging.
Was this page helpful?
Last updated on