BetterIAM

Verification and recovery

Email verification, password reset and change, email and phone changes, lost second factors, clearing lockouts, and recovering root access.

@better-iam/auth@better-iam/server@better-iam/clientauthentication.mdsecurity.mdaccount.tsbase.tsidentities.tsapi.json

People forget passwords, change addresses, and lose phones. These flows let them recover without an administrator, and let administrators help when they must, without ever revealing whether an account exists.

Each flow proves that the person controls an address by sending a single-use token or code there, through the . So each needs the matching callback (sendEmail, or sendSms for phone codes) and fails with FEATURE_DISABLED without it. Each flow has a start call that sends the message and a completion call that redeems it:

FlowStarts withCompletes withToken lifetimeTemplate
Email verificationauth.requestEmailVerificationauth.verifyEmail24 hoursverify-email
Password resetauth.requestPasswordResetauth.resetPassword10 minutespassword-reset
Email changeauth.requestEmailChangeauth.confirmEmailChange10 minutesemail-change
Phone verificationauth.startPhoneVerificationauth.confirmPhoneVerification5 minutesphone-verify (SMS)

Setup

Deliver email and SMS

Configure sendEmail (and sendSms for phone numbers). renderDeliveryMessage from better-iam/auth/templates turns the built-in templates into a subject, text, and HTML body; the link builders decide which of your pages each email opens.

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

const origin = 'https://app.example.com';

export const iam = betterIam({
  database: sqliteAdapter({ filename: './iam.db' }),
  secret: process.env.BETTER_IAM_SECRET,
  baseURL: origin,
  authentication: {
    requireEmailVerification: true,
    sendEmail: async (message) => {
      const tenant = message.tenantId;
      const rendered = renderDeliveryMessage(message, {
        appName: 'Acme Cloud',
        links: {
          passwordReset: ({ token }) => `${origin}/reset?tenant=${tenant}&token=${token}`,
          verifyEmail: ({ token }) => `${origin}/verify?tenant=${tenant}&token=${token}`,
          emailChange: ({ token }) => `${origin}/email?tenant=${tenant}&token=${token}`,
        },
      });
      if (!rendered) throw new Error(`Unknown template ${message.template}`); // the outbox retries later
      await mailer.send({ to: message.to, ...rendered });
    },
  },
});

Delivery is at least once: deduplicate by message.id, and never log tokens or payloads. See Email and SMS templates.

Migrate

Tokens and codes are stored in the authChallenges collection that migrate creates:

npx better-iam migrate --config better-iam.config.mjs

Add the landing pages

Each email links to a page of yours that reads tenant and token from the URL and calls the completion method: auth.verifyEmail, auth.resetPassword (with the new password), or auth.confirmEmailChange. The calls below show each one.

Usage

Email verification

An email address typed at sign-up could belong to anyone. Before you send password resets, security alerts, or one-time codes there, you want proof that the person actually receives mail at it. A person's email is verified when they accept an invitation, finish an emailed passwordless sign-in, confirm an email change, or follow a verification link.

  • auth.requestEmailVerification sends a verification link. It is public and always answers { success: true }, whether or not the address exists, and it sends only to an active person whose address is not yet verified.
  • auth.verifyEmail redeems the token from the link, on the page it opens, and marks the address verified (audited as auth:email:verify). The link is valid for 24 hours.
import { client } from './lib/iam-client';

await client.auth.requestEmailVerification({ tenantId, email: 'bob@example.com' });

// Later, on the page the link opens:
await client.auth.verifyEmail({ tenantId, token });

Routes: POST /api/iam/auth/requestEmailVerification, POST /api/iam/auth/verifyEmail

With authentication.requireEmailVerification on (it defaults to the value of signUpEnabled), an unverified person cannot sign in and existing sessions stop working: both fail with EMAIL_UNVERIFIED. Some features also depend on a verified address: password reset emails, emailed MFA codes, and failed sign-in alerts are only sent to verified addresses.

Password reset

A forgotten password should not need a support ticket. Password reset proves control of the verified email address instead, then lets the person choose a new password.

Request a reset

auth.requestPasswordReset sends a reset link. Call it from your "forgot password" form.

import { client } from './lib/iam-client';

await client.auth.requestPasswordReset({ tenantId, email: 'alice@example.com' });

Route: POST /api/iam/auth/requestPasswordReset

The call always succeeds, so it cannot be used to discover accounts. A password-reset email is sent only when the address belongs to an active person in that tenant whose email is verified. Without sendEmail, or with emailPassword: false, it fails with FEATURE_DISABLED.

Set the new password

On the page the email links to (your links.passwordReset builder decides the URL), auth.resetPassword redeems the token and sets the new password:

import { client } from './lib/iam-client';

await client.auth.resetPassword({ tenantId, token, password: newPassword });

Route: POST /api/iam/auth/resetPassword

The token is single-use and valid for ten minutes; an invalid or expired one fails with INVALID_CHALLENGE. Every password rule applies: the deployment screen and the tenant's length, character class, personal information, and history rules (WEAK_PASSWORD, BREACHED_PASSWORD, PASSWORD_REUSED).

A reset ends every session of the person, forgets their remembered devices, and cancels other pending challenges. It never signs them in and never removes MFA: the person signs in with the new password and then their second factor. The reset is audited as auth:password:reset.

A reset is also the way back from an expired password. When a tenant sets passwordMaxAgeDays, a correct but expired password is refused with PASSWORD_EXPIRED, only after it has been verified, so expiry never reveals whether a guess was right.

Resetting on someone's behalf

Sometimes a person cannot start the reset themselves, for example because their address was never verified or they were created without a password. identities.requestPasswordReset lets an administrator queue the reset email for a member, whether their address is verified or not:

import { iam } from './iam';

const { queued, email } = await iam.api.identities.requestPasswordReset(adminCredential, {
  tenantId,
  identityId,
});

Route: POST /api/iam/identities/requestPasswordReset

This needs iam:identities:update and recent authentication and is audited as identity:password-reset. Only an owner of the same tenant, or a root administrator, can trigger a reset for an owner; only a root administrator can for a root administrator. Refusals are audited as denials.

Password change

People change a password they still know when they suspect it leaked, or simply to rotate it. auth.changePassword takes the current password and the new one:

import { client } from './lib/iam-client';

await client.auth.changePassword({ currentPassword, password: newPassword });

Route: POST /api/iam/auth/changePassword

  • It needs recent authentication (RECENT_AUTH_REQUIRED); see recent authentication.
  • A wrong current password fails with INVALID_CREDENTIALS and is recorded as a failed attempt, like one at sign-in: someone holding a session may be guessing it.
  • On success, every session of the person ends, including the one that made the change, and remembered devices are forgotten. Send people back to sign in with the new password. Audited as auth:password:change.

Email change

People change jobs, domains, and providers, and their sign-in address has to follow. Because the address is also where resets go, a change must prove the new address before it takes effect. It happens in two steps:

  1. auth.requestEmailChange sends a confirmation link to the new address. It needs a signed-in, recently authenticated session.
  2. auth.confirmEmailChange redeems the token from that link and switches the account to the new address.
import { client } from './lib/iam-client';

// Signed in, with recent authentication:
await client.auth.requestEmailChange({ email: 'alice@new.example' });

// On the page the confirmation email links to:
await client.auth.confirmEmailChange({ tenantId, token });

Routes: POST /api/iam/auth/requestEmailChange, POST /api/iam/auth/confirmEmailChange

  • The email-change message goes to the new address and is valid for ten minutes.
  • The token is bound to the session that asked for it. If that session has ended, confirmation fails with UNAUTHENTICATED.
  • An address already used by another identity in the tenant fails with IDENTITY_EXISTS.
  • On confirmation the new address is verified, every session ends, and remembered devices are forgotten. Audited as auth:email:change.

Administrators change a member's address with identities.update({ email }). That needs recent authentication, leaves the new address unverified, revokes the person's sessions, and is audited as identity:email-change. The same owner and root protections as for password resets apply.

Phone numbers

A verified phone number enables SMS sign-in codes (when passwordlessSms is on). Verification proves the number belongs to the person before codes are sent there. They add one from a signed-in, recently authenticated session: auth.startPhoneVerification texts a code to the number, and auth.confirmPhoneVerification checks the code and marks the number verified.

import { client } from './lib/iam-client';

await client.auth.startPhoneVerification({ phone: '+15551234567' }); // E.164
await client.auth.confirmPhoneVerification({ phone: '+15551234567', code: '482913' });

Routes: POST /api/iam/auth/startPhoneVerification, POST /api/iam/auth/confirmPhoneVerification

The six-digit code is sent by SMS with the phone-verify template, is valid for five minutes, and is bound to the session that asked for it. A wrong code fails with INVALID_CHALLENGE. A number already verified by another identity in the tenant fails with PHONE_EXISTS. Verification is audited as auth:phone:verify.

A lost second factor

When a person loses their authenticator, they sign in with their password and answer the challenge with one of their ten single-use recovery codes (auth.recoverMfa). Once signed in, they can enroll a new authenticator or regenerate their codes; see recovery codes. A registered passkey can also answer the challenge. Resetting a password does not remove MFA, so a stolen mailbox alone is not enough to take over an account that has a second factor.

There is no API that removes another person's authenticator, so an administrator cannot reset someone's MFA in place. A person who lost their authenticator, their recovery codes, and every passkey cannot complete the challenge. Deleting the identity (identities.delete) removes its factors together with its bindings, memberships, and email, after which the person can be invited again and given their access back. When the locked-out person is the last root administrator, recover root access instead.

Lockouts

Rate limits that stop an attacker also stop a person who mistyped their password too often. Sign-in, recovery, and MFA flows share persisted rate limits (authentication.rateLimits, tightened per tenant with maxAttempts). Recovery requests and code checks use the stricter sensitive tier. A refused attempt fails with RATE_LIMITED (429). The error carries retryAfterMs, the HTTP response adds a Retry-After header, and IamClientError.retryAfterMs exposes it to browser code. See rate limits.

The counters reset by themselves when the window passes. When someone cannot wait, identities.unlock lets an administrator clear that person's counters right away:

import { iam } from './iam';

const { supported, cleared } = await iam.api.identities.unlock(adminCredential, { tenantId, identityId });

Route: POST /api/iam/identities/unlock

identities.unlock needs iam:identities:update and recent authentication and is audited as identity:unlock. It clears the counters behind the person's sign-in, recovery, and MFA flows, keyed by their email, phone, and ID. It never clears a network's per-IP counter. A custom limiter opts in by implementing reset; without it the call reports supported: false.

Recovering root access

When every root administrator is locked out, a deployment operator creates a new root administrator with iam.recoverRoot, usually through the recover-root CLI command. It is a deployment capability, never routed over HTTP. It needs an email address the root tenant does not use yet (IDENTITY_EXISTS otherwise), and the new administrator enrolls MFA on first sign-in like the first one did. See root administration.

Options

OptionTypeDefaultDescription
authentication.sendEmail(message: DeliveryMessage) => Promise<void>noneDelivers verification, reset, and email-change links. Required by every email flow.
authentication.sendSms(message: DeliveryMessage) => Promise<void>noneDelivers phone verification codes.
authentication.requireEmailVerificationbooleanvalue of signUpEnabledUnverified people cannot sign in, and their sessions stop working.
authentication.emailPasswordbooleantruefalse turns off password sign-in, reset, and self-registration.
authentication.recentAuthenticationMsnumber5 minutesHow recent a session must be to change a password, email, or phone.
authentication.rateLimits.sensitiveAttemptsnumber5Attempts per window for recovery requests and code checks.
authentication.passwordPolicyPasswordPolicyOptionscommon passwords blockedDeployment-wide screening applied on reset and change.
authentication.maxDeliveryAttemptsnumber25Outbox messages are abandoned after this many failed deliveries.
Tenant password rulesminPasswordLength, passwordMinClasses, passwordRejectPersonalInfo, passwordHistory, passwordMaxAgeDaysunsetSee Tenant policy.

Schema

CollectionKey fieldsNotes
authChallengesid, tenantId, identityId, purpose, tokenHash, expiresAt, payload, sessionIdSingle-use tokens and codes for each flow, stored as a keyed digest. sessionId binds email-change and phone codes to the session that asked.
identitiesemailVerified, phone, phoneVerified, passwordHash, passwordChangedAtThe fields these flows change.
passwordHistoryidentityId, hash, createdAtEarlier password hashes (newest 24 per identity) for the tenant's passwordHistory rule.
authRateLimitscount, resetAtPersisted rate-limit counters, when the default limiter is used.
outboxtemplate, to, sealed payloadMessages waiting for delivery.

Errors

CodeWhen
FEATURE_DISABLEDNo sendEmail (or sendSms) is configured, or password authentication is off.
INVALID_CHALLENGEA token or code is invalid, expired, already used, or the address changed since it was sent.
INVALID_CREDENTIALSchangePassword with a wrong current password.
WEAK_PASSWORDThe new password fails the length, class, or personal-information rules.
BREACHED_PASSWORDThe new password is in the breach corpus.
PASSWORD_REUSEDThe new password matches one of the remembered earlier passwords.
PASSWORD_EXPIREDA correct password is past the tenant's passwordMaxAgeDays.
EMAIL_UNVERIFIEDVerification is required and the address is unverified.
IDENTITY_EXISTSThe new email address is already used in the tenant.
PHONE_EXISTSThe number is already verified by another identity in the tenant.
UNAUTHENTICATEDThe session that requested an email change has ended.
RECENT_AUTH_REQUIREDA change from a session that is not recent.
RATE_LIMITEDToo many requests or attempts in the window.
ACCESS_DENIEDAn administrator lacks iam:identities:update, or targets an owner or root administrator they may not.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page