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.
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:
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 });
}| Template | Queued by | Valid for |
|---|---|---|
verify-email | auth.signUp (when verification is required) and auth.requestEmailVerification | 24 hours |
password-reset | auth.requestPasswordReset and identities.requestPasswordReset | 10 minutes |
email-change | auth.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.mjsMigrate 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.mjsCreate the client
Browser code calls the auth methods through the typed client. Mount the HTTP handler first; see
HTTP and configuration.
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.
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.createor 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 }).
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:failwithreason: 'password'and counted in their sign-in record. - With
requireEmailVerificationon, an unverified person fails withEMAIL_UNVERIFIED. A password past the tenant'spasswordMaxAgeDaysfails withPASSWORD_EXPIRED, only after it was verified. deviceTokenfrom "remember this device" skips the second factor; through the HTTP handler it is sent from thebetter-iam.devicecookie automatically.- The tenant's
allowedMethodsmust includepassword(METHOD_NOT_ALLOWED), checked before the password. - The session records
method: 'password', which policies read asprincipal.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.
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
requestEmailVerificationalways answers{ success: true }. It sends only to an active person whose address is not verified yet, and fails withFEATURE_DISABLEDwithoutsendEmail.- 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.
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
requestPasswordResetalways succeeds, so it cannot be used to discover accounts. Apassword-resetemail 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_DISABLEDwhenemailPasswordisfalse; the request also needssendEmail.
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.
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.
import { iam } from './iam';
const result = await iam.api.auth.reauthenticate({ headers: request.headers }, { password });Route: POST /auth/reauthenticate
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.
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:
| Rule | Set by | Error |
|---|---|---|
| 12 to 1,024 characters | Always | WEAK_PASSWORD (too short), INVALID_INPUT (too long) |
| Minimum length, 12 to 128 | Tenant minPasswordLength | WEAK_PASSWORD |
| 2 to 4 character classes (lowercase, uppercase, digits, symbols) | Tenant passwordMinClasses | WEAK_PASSWORD |
| No email local part or name word of four or more characters | Tenant passwordRejectPersonalInfo | WEAK_PASSWORD |
| Not a well-known password, keyboard walk, or sequence, and at least five distinct characters | Deployment passwordPolicy.blockCommonPasswords (default on) | WEAK_PASSWORD |
| Your own rule | Deployment passwordPolicy.check | WEAK_PASSWORD with your message |
| Not in a breach corpus | Deployment passwordPolicy.isBreached | BREACHED_PASSWORD, or PASSWORD_CHECK_UNAVAILABLE (503) when a fail-closed check cannot answer |
| Not one of the last 1 to 24 passwords | Tenant passwordHistory | PASSWORD_REUSED |
| Expires after N days | Tenant passwordMaxAgeDays | PASSWORD_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.
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.
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
argon2package, using its default cost parameters. The hash is stored on the identity aspasswordHashand 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.
passwordHistorycompares 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:
- Create each person without a password, with
identities.createoridentities.createMany. - 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.
| Option | Type | Default | Description |
|---|---|---|---|
emailPassword | boolean | true | Enables password sign-in, sign-up, reset, and re-authentication. false makes them fail with FEATURE_DISABLED. |
signUpEnabled | boolean | false | Lets visitors create their own account with auth.signUp. |
requireEmailVerification | boolean | value of signUpEnabled | Refuses sign-in, and existing sessions, until the address is verified (EMAIL_UNVERIFIED). Needs sendEmail. |
sendEmail | (message: DeliveryMessage) => Promise<void> | none | Delivers verification, reset, and email-change messages. Throw to have the outbox retry. |
recentAuthenticationMs | number | 300000 (5 minutes) | How long after sign-in a session counts as recent for changePassword and other sensitive calls. |
failedSignInAlerts | number | 0 | Emails 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. |
rateLimits | RateLimitOptions | 10 attempts per 15 minutes | Per-account counters for sign-in and recovery; see rate limits. |
passwordPolicy.blockCommonPasswords | boolean | true | The built-in screen against well-known passwords, keyboard walks, sequences, and low variety. |
passwordPolicy.isBreached | (password: string) => Promise<boolean> | none | Returns true for a breached password (BREACHED_PASSWORD). |
passwordPolicy.check | (password, { tenantId, identity? }) => string | undefined | none | Your own rule: return a message to reject the password. May be async. |
pwnedPasswords(options) accepts:
| Option | Type | Default | Description |
|---|---|---|---|
threshold | number | 1 | The minimum breach count that rejects a password. |
failClosed | boolean | false | Refuse with PASSWORD_CHECK_UNAVAILABLE when the service cannot be reached, instead of accepting the password. |
timeoutMs | number | 3000 | Request timeout. |
endpoint | string | https://api.pwnedpasswords.com/range/ | The range API to query, for a self-hosted mirror. |
fetch | typeof fetch | globalThis.fetch | The Fetch implementation. |
Organization rules (minPasswordLength, passwordMinClasses, passwordRejectPersonalInfo, passwordHistory,
passwordMaxAgeDays) are fields of the tenant policy.
Schema
| Collection | Key fields | Purpose |
|---|---|---|
identities | email (lowercased, unique per tenant), emailVerified, passwordHash, passwordChangedAt, unprovenPassword | The person and their current Argon2id hash. passwordChangedAt drives passwordMaxAgeDays. |
passwordHistory | identityId, hash, createdAt | Earlier hashes for passwordHistory, at most 24 per person. |
authChallenges | purpose (verify-email, password-reset, email-change), tokenHash, identityId, expiresAt, payload | Single-use emailed tokens, stored as keyed digests. |
outbox | template, to, sealed payload | Queued messages until the outbox worker delivers them. |
sessions | kind: 'user', method: 'password', tokenHash | The sessions a sign-in issues; see sessions. |
Errors
| Code | When |
|---|---|
INVALID_CREDENTIALS | Wrong password, unknown address, no password, or inactive account (always indistinguishable). |
EMAIL_UNVERIFIED | Verification is required and the address is not verified yet. |
PASSWORD_EXPIRED | The password is older than the tenant's passwordMaxAgeDays; reset it. |
WEAK_PASSWORD | The password breaks a length, variety, personal-information, common-password, or custom rule. |
BREACHED_PASSWORD | passwordPolicy.isBreached found the password in a breach corpus. |
PASSWORD_REUSED | The password matches one of the last passwordHistory passwords. |
PASSWORD_CHECK_UNAVAILABLE | A fail-closed breach check could not reach its service. |
IDENTITY_EXISTS | Sign-up or creation with an address already used in the tenant. |
FEATURE_DISABLED | Sign-up without signUpEnabled, any password flow with emailPassword: false, or an email flow without sendEmail. |
FORBIDDEN | Sign-up in the root tenant. |
METHOD_NOT_ALLOWED | The tenant's allowedMethods excludes password. |
INVALID_CHALLENGE | An emailed token is wrong, used, expired, or was sent to an address that has since changed. |
RECENT_AUTH_REQUIRED | changePassword from a session that is not recent. |
RATE_LIMITED | Too many attempts; the error carries retryAfterMs. |
LIMIT_EXCEEDED | The tenant reached its member limit. |
TENANT_UNAVAILABLE | The tenant or one of its ancestors is suspended or missing. |
Next steps
Answer the MFA challenge a password sign-in can return.
Verification and recoveryEmail changes, phone numbers, lost second factors, and lockouts.
Tenant policyPer-organization password rules, allowed methods, and session limits.
SessionsWhat a sign-in issues, and how sessions end.
Better IAM is created by Sean Filimon
Last updated
Sign-in methods
An overview of every way to sign in to Better IAM (passwords, magic links and codes, passkeys, federation, API keys, temporary tokens, and assumed roles) and how to enable and restrict them.
Magic links and codes
Passwordless sign-in in Better IAM with emailed magic links, emailed six-digit codes, and SMS codes, using auth.startPasswordless and auth.finishPasswordless.