BetterIAM

Email and password

Password sign-up and sign-in, email verification, password reset and change, deployment and organization password rules, and Argon2id hashing in Better IAM.

Email and password is the sign-in method most people already know, and it is on by default. A person signs in to one with an email address and a password; the result is a , or an MFA challenge when a second factor is required. Better IAM hashes every password with Argon2id, screens new passwords against weak and breached ones, never reveals whether an account exists, and lets each organization add its own password rules.

Use it when people expect a password, or alongside magic links and codes, passkeys, and federated sign-in. Turn it off with emailPassword: false for passwordless-only or federation-only products.

Setup

Configure the server

Password sign-in needs no option: authentication.emailPassword defaults to true. Self-registration is off by default, because most business products add people by invitation. Email verification, password reset, and email change send messages, so they need sendEmail.

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

export const iam = betterIam({
  database: sqliteAdapter({ filename: './iam.db' }),
  secret: process.env.BETTER_IAM_SECRET!,
  baseURL: process.env.BETTER_IAM_BASE_URL!,
  authentication: {
    emailPassword: true, // the default
    signUpEnabled: true, // let visitors register themselves (default false)
    // requireEmailVerification defaults to signUpEnabled
    sendEmail: async (message) => sendMail(message), // deduplicate retries by message.id
    passwordPolicy: {
      isBreached: pwnedPasswords(), // Have I Been Pwned, k-anonymity
    },
  },
});

Construction fails with INVALID_CONFIG when requireEmailVerification is on (explicitly, or through signUpEnabled) and sendEmail is missing.

Render the emails

Better IAM never sends mail itself. It queues a message with a template name and a token in its payload, and your sendEmail callback delivers it. renderDeliveryMessage from better-iam/auth/templates turns the built-in templates into { subject, text, html } and builds links to your own pages:

mailer.ts
import type { DeliveryMessage } from 'better-iam/auth';
import { renderDeliveryMessage } from 'better-iam/auth/templates';
import { transport } from './smtp';

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

export async function sendMail(message: DeliveryMessage): Promise<void> {
  const rendered = renderDeliveryMessage(message, {
    appName: 'Acme Cloud',
    links: {
      verifyEmail: ({ tenantId, token }) => `${origin}/verify-email?tenant=${tenantId}&token=${token}`,
      passwordReset: ({ tenantId, token }) => `${origin}/reset-password?tenant=${tenantId}&token=${token}`,
      emailChange: ({ tenantId, token }) => `${origin}/confirm-email?tenant=${tenantId}&token=${token}`,
    },
  });
  if (!rendered) throw new Error(`No renderer for ${message.template}`); // the outbox retries later
  await transport.send({ id: message.id, to: message.to, ...rendered });
}
TemplateQueued byValid for
verify-emailauth.signUp (when verification is required) and auth.requestEmailVerification24 hours
password-resetauth.requestPasswordReset and identities.requestPasswordReset10 minutes
email-changeauth.requestEmailChange (sent to the new address)10 minutes

Deliver the outbox

Messages are queued inside the transaction that creates them and delivered after commit by the worker. Schedule it every minute, or call iam.auth.dispatchOutbox() from your own scheduler. See scheduled jobs.

npx better-iam outbox --config better-iam.config.mjs

Migrate the database

Passwords, password history, and emailed tokens live in collections that migrate creates with the rest of the schema.

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

Create the client

Browser code calls the auth methods through the typed client. Mount the HTTP handler first; see HTTP and configuration.

lib/iam-client.ts
import { createIamClient } from 'better-iam/client';
import type { iam } from './iam';

export const client = createIamClient<typeof iam>();

Usage

Server code calls iam.api.auth.<method>. Public methods (sign-up, sign-in, and the redemption of emailed tokens) take only an input. Methods that act on the caller's own account take a credential first: { headers: request.headers } or { token }. Browser code calls the same methods on the client without the credential, because the session cookie is the credential. Routes are relative to the handler's basePath (/api/iam by default).

Sign up

auth.signUp creates a person in an existing tenant. It needs signUpEnabled: true, never creates an identity in the root tenant (FORBIDDEN), and never signs the person in. When email verification is required, it queues a verify-email message and returns verificationRequired: true.

app/api/register.ts
import { iam } from './iam';

const { identity, verificationRequired } = await iam.api.auth.signUp({
  tenantId,
  email: 'bob@example.com',
  name: 'Bob',
  password: 'a long passphrase of my own',
});

Route: POST /auth/signUp

  • The address is lowercased and must be unique in the tenant (IDENTITY_EXISTS).
  • Every password rule applies (WEAK_PASSWORD, BREACHED_PASSWORD), and tenant member limits apply (LIMIT_EXCEEDED).
  • Signing up grants no access. Bind roles with bindings.create or add the person to a group.
  • Audited as auth:identity:create.

Sign in

auth.signIn checks the password and returns either a session ({ token, session }) or an MFA challenge ({ mfaRequired: true, challenge, enrollmentRequired, emailCodeAvailable?, passkeyAvailable? }). The tenant is never inferred from the email address: you always pass tenantId, for example from tenants.lookup({ slug }).

app/api/login.ts
import { iam } from './iam';

// withClient records the real client, so network rules, per-address rate limits, and the sign-in record see it.
const result = await iam.auth.withClient({ ip, userAgent }, () =>
  iam.api.auth.signIn({ tenantId, email: 'alice@example.com', password }),
);
if ('mfaRequired' in result) {
  // Ask for a second factor: iam.api.auth.verifyMfa({ tenantId, challenge: result.challenge, code })
} else {
  // result.token is the bearer token; result.session describes the new session.
}

Route: POST /auth/signIn

  • A wrong password, an unknown address, an account without a password, and a disabled or expired account all fail with the same INVALID_CREDENTIALS (401). Unknown and passwordless-only accounts perform the same Argon2 work against a dummy hash, so response times do not reveal which accounts exist.
  • A wrong password for a real, active person is recorded as auth:signin:fail with reason: 'password' and counted in their sign-in record.
  • With requireEmailVerification on, an unverified person fails with EMAIL_UNVERIFIED. A password past the tenant's passwordMaxAgeDays fails with PASSWORD_EXPIRED, only after it was verified.
  • deviceToken from "remember this device" skips the second factor; through the HTTP handler it is sent from the better-iam.device cookie automatically.
  • The tenant's allowedMethods must include password (METHOD_NOT_ALLOWED), checked before the password.
  • The session records method: 'password', which policies read as principal.authMethod.

Multi-factor authentication covers every way to answer the challenge.

Verify the email address

An address typed at sign-up could belong to anyone. A person's email is verified when they follow a verification link, accept an invitation, finish an emailed magic link or code, or confirm an email change. auth.requestEmailVerification sends a new link; auth.verifyEmail redeems the token on the page the link opens.

app/api/verify-email.ts
import { iam } from './iam';

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

// On the page the link opens:
await iam.api.auth.verifyEmail({ tenantId, token });

Route: POST /auth/requestEmailVerification, POST /auth/verifyEmail

  • requestEmailVerification always answers { success: true }. It sends only to an active person whose address is not verified yet, and fails with FEATURE_DISABLED without sendEmail.
  • The link is valid for 24 hours and works once. It verifies only the address it was sent to: after any change of address it fails with INVALID_CHALLENGE.
  • Verification is audited as auth:email:verify.

Reset a forgotten password

A reset proves control of the verified address, then sets a new password. auth.requestPasswordReset sends the link; auth.resetPassword redeems the token and sets the password.

app/api/reset-password.ts
import { iam } from './iam';

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

// On the page the email links to:
await iam.api.auth.resetPassword({ tenantId, token, password: newPassword });

Route: POST /auth/requestPasswordReset, POST /auth/resetPassword

  • requestPasswordReset always succeeds, so it cannot be used to discover accounts. A password-reset email goes out only when the address belongs to an active person in that tenant whose email is verified.
  • The token is single-use and valid for ten minutes. Every password rule applies.
  • A reset ends every session of the person, forgets their remembered devices, and cancels pending challenges. It never signs them in and never removes MFA. Audited as auth:password:reset.
  • Both calls fail with FEATURE_DISABLED when emailPassword is false; the request also needs sendEmail.

Administrators send the reset email for a member, verified or not, with identities.requestPasswordReset; see resetting on someone's behalf.

Change the password

auth.changePassword replaces a password the person still knows. It needs a recently authenticated session (RECENT_AUTH_REQUIRED), and a wrong current password counts as a failed attempt, like one at sign-in.

app/api/change-password.ts
import { iam } from './iam';

export async function POST(request: Request) {
  const { currentPassword, password } = await request.json();
  await iam.api.auth.changePassword({ headers: request.headers }, { currentPassword, password });
  return Response.json({ signedOut: true });
}

Route: POST /auth/changePassword

On success every session of the person ends, including the one that made the change, and remembered devices are forgotten. Audited as auth:password:change.

Confirm the password again

Sensitive operations need a session established within recentAuthenticationMs (five minutes by default). auth.reauthenticate checks the password again and returns a fresh session, or an MFA challenge when one is required, without a full sign-in.

app/api/reauthenticate.ts
import { iam } from './iam';

const result = await iam.api.auth.reauthenticate({ headers: request.headers }, { password });

Route: POST /auth/reauthenticate

See recent authentication.

Create people with a password

Administrators can set a first password when they create a person, invite them, or onboard many at once. The same password rules apply as at sign-up.

scripts/add-member.ts
import { iam } from './iam';

const person = await iam.api.identities.create(adminCredential, {
  tenantId,
  email: 'carol@example.com',
  name: 'Carol',
  password: initialPassword, // optional: omit it and send a reset link instead
});
await iam.api.identities.requestPasswordReset(adminCredential, { tenantId, identityId: person.id });

Route: POST /identities/create, POST /identities/requestPasswordReset

identities.createMany accepts password per entry, and invitations (identities.invite, then identities.acceptInvitation) let the invited person choose their own. A person created without a password signs in with a magic link, a passkey, or federation, or sets a password through reset.

Password policy

Every password rule applies wherever a password is set: sign-up, identity creation, invitations, bulk onboarding, reset, and change. Rules are checked in this order:

RuleSet byError
12 to 1,024 charactersAlwaysWEAK_PASSWORD (too short), INVALID_INPUT (too long)
Minimum length, 12 to 128Tenant minPasswordLengthWEAK_PASSWORD
2 to 4 character classes (lowercase, uppercase, digits, symbols)Tenant passwordMinClassesWEAK_PASSWORD
No email local part or name word of four or more charactersTenant passwordRejectPersonalInfoWEAK_PASSWORD
Not a well-known password, keyboard walk, or sequence, and at least five distinct charactersDeployment passwordPolicy.blockCommonPasswords (default on)WEAK_PASSWORD
Your own ruleDeployment passwordPolicy.checkWEAK_PASSWORD with your message
Not in a breach corpusDeployment passwordPolicy.isBreachedBREACHED_PASSWORD, or PASSWORD_CHECK_UNAVAILABLE (503) when a fail-closed check cannot answer
Not one of the last 1 to 24 passwordsTenant passwordHistoryPASSWORD_REUSED
Expires after N daysTenant passwordMaxAgeDaysPASSWORD_EXPIRED at sign-in

Deployment screening

authentication.passwordPolicy sets the baseline for every tenant. pwnedPasswords() from better-iam/auth is a Have I Been Pwned client that uses k-anonymity: it sends only the first five characters of the password's SHA-1 hash and matches the returned suffixes locally.

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

export const iam = betterIam({
  // database, secret, baseURL ...
  authentication: {
    passwordPolicy: {
      blockCommonPasswords: true, // the default
      isBreached: pwnedPasswords({
        threshold: 1, // minimum breach count that rejects a password
        failClosed: true, // refuse with PASSWORD_CHECK_UNAVAILABLE when the service cannot be reached
        timeoutMs: 3_000,
      }),
      check: (password, { tenantId, identity }) =>
        /acme/i.test(password) ? 'Do not use the company name' : undefined,
    },
  },
});

Organization rules

Each organization tightens the baseline with its tenant policy, set with tenants.setAuthPolicy. A tenant can never loosen the deployment's screening. setAuthPolicy replaces the whole policy, so read the current one first (tenants.get returns it as authPolicy) when you change one field.

scripts/password-rules.ts
import { iam } from './iam';

await iam.api.tenants.setAuthPolicy(ownerCredential, {
  tenantId,
  authPolicy: {
    minPasswordLength: 14,
    passwordMinClasses: 3,
    passwordRejectPersonalInfo: true,
    passwordHistory: 5,
    passwordMaxAgeDays: 365,
  },
});

Hashing

  • Passwords are hashed with Argon2id through the argon2 package, using its default cost parameters. The hash is stored on the identity as passwordHash and never leaves the server: every API result strips it.
  • The hashing function is not configurable, and there is no option to plug in another algorithm.
  • passwordHistory compares a new password with Argon2 against the current hash and as many earlier hashes as the tenant asks for. Up to 24 earlier hashes are kept per person, and they are deleted with the identity.
  • Emailed tokens (verification, reset, email change) are stored only as HMAC-SHA-256 digests keyed by the deployment secret, in the challenge store.

Importing passwords from another provider

Better IAM does not import password hashes. No API accepts a precomputed hash, and there is no hook that verifies a legacy hash at first sign-in. Passwords are only ever set from plaintext through the calls above. To move people from another system:

  1. Create each person without a password, with identities.create or identities.createMany.
  2. Send each person a reset link with identities.requestPasswordReset, or let them sign in with a magic link, a passkey, or federation.

The migration guides walk through this for specific providers.

Self-registration and address ownership

A self-registered account starts with a password nobody has proven belongs to the owner of the address. Better IAM records this as unprovenPassword and protects the real owner:

  • Verifying the address (auth.verifyEmail) or resetting the password clears the flag: the registrant proved they receive mail there.
  • If the first proof of the address is instead an emailed magic link or code, the owner signing in removes the password chosen at sign-up and ends every session and remembered device it produced (audited as auth:password:clear). Someone who registered an address they do not own cannot keep the account once the owner signs in.
  • Accounts created by an administrator keep their password.

Options

Deployment options live under authentication in the server options.

OptionTypeDefaultDescription
emailPasswordbooleantrueEnables password sign-in, sign-up, reset, and re-authentication. false makes them fail with FEATURE_DISABLED.
signUpEnabledbooleanfalseLets visitors create their own account with auth.signUp.
requireEmailVerificationbooleanvalue of signUpEnabledRefuses sign-in, and existing sessions, until the address is verified (EMAIL_UNVERIFIED). Needs sendEmail.
sendEmail(message: DeliveryMessage) => Promise<void>noneDelivers verification, reset, and email-change messages. Throw to have the outbox retry.
recentAuthenticationMsnumber300000 (5 minutes)How long after sign-in a session counts as recent for changePassword and other sensitive calls.
failedSignInAlertsnumber0Emails a sign-in-failures alert to a verified address when this many attempts (wrong password, second factor, or recovery code) have failed since the last sign-in. At most 1000. Needs sendEmail.
rateLimitsRateLimitOptions10 attempts per 15 minutesPer-account counters for sign-in and recovery; see rate limits.
passwordPolicy.blockCommonPasswordsbooleantrueThe built-in screen against well-known passwords, keyboard walks, sequences, and low variety.
passwordPolicy.isBreached(password: string) => Promise<boolean>noneReturns true for a breached password (BREACHED_PASSWORD).
passwordPolicy.check(password, { tenantId, identity? }) => string | undefinednoneYour own rule: return a message to reject the password. May be async.

pwnedPasswords(options) accepts:

OptionTypeDefaultDescription
thresholdnumber1The minimum breach count that rejects a password.
failClosedbooleanfalseRefuse with PASSWORD_CHECK_UNAVAILABLE when the service cannot be reached, instead of accepting the password.
timeoutMsnumber3000Request timeout.
endpointstringhttps://api.pwnedpasswords.com/range/The range API to query, for a self-hosted mirror.
fetchtypeof fetchglobalThis.fetchThe Fetch implementation.

Organization rules (minPasswordLength, passwordMinClasses, passwordRejectPersonalInfo, passwordHistory, passwordMaxAgeDays) are fields of the tenant policy.

Schema

CollectionKey fieldsPurpose
identitiesemail (lowercased, unique per tenant), emailVerified, passwordHash, passwordChangedAt, unprovenPasswordThe person and their current Argon2id hash. passwordChangedAt drives passwordMaxAgeDays.
passwordHistoryidentityId, hash, createdAtEarlier hashes for passwordHistory, at most 24 per person.
authChallengespurpose (verify-email, password-reset, email-change), tokenHash, identityId, expiresAt, payloadSingle-use emailed tokens, stored as keyed digests.
outboxtemplate, to, sealed payloadQueued messages until the outbox worker delivers them.
sessionskind: 'user', method: 'password', tokenHashThe sessions a sign-in issues; see sessions.

Errors

CodeWhen
INVALID_CREDENTIALSWrong password, unknown address, no password, or inactive account (always indistinguishable).
EMAIL_UNVERIFIEDVerification is required and the address is not verified yet.
PASSWORD_EXPIREDThe password is older than the tenant's passwordMaxAgeDays; reset it.
WEAK_PASSWORDThe password breaks a length, variety, personal-information, common-password, or custom rule.
BREACHED_PASSWORDpasswordPolicy.isBreached found the password in a breach corpus.
PASSWORD_REUSEDThe password matches one of the last passwordHistory passwords.
PASSWORD_CHECK_UNAVAILABLEA fail-closed breach check could not reach its service.
IDENTITY_EXISTSSign-up or creation with an address already used in the tenant.
FEATURE_DISABLEDSign-up without signUpEnabled, any password flow with emailPassword: false, or an email flow without sendEmail.
FORBIDDENSign-up in the root tenant.
METHOD_NOT_ALLOWEDThe tenant's allowedMethods excludes password.
INVALID_CHALLENGEAn emailed token is wrong, used, expired, or was sent to an address that has since changed.
RECENT_AUTH_REQUIREDchangePassword from a session that is not recent.
RATE_LIMITEDToo many attempts; the error carries retryAfterMs.
LIMIT_EXCEEDEDThe tenant reached its member limit.
TENANT_UNAVAILABLEThe tenant or one of its ancestors is suspended or missing.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page