# Email and password (/docs/authentication/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 tenant with an email address and a password; the result is a
session, 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](/docs/authentication/passwordless),
[passkeys](/docs/authentication/passkeys), and [federated sign-in](/docs/federation/oauth-sign-in). Turn it off with
`emailPassword: false` for passwordless-only or federation-only products.

## Setup [#setup]

      ### Configure the server [#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`.

    ```ts title="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 [#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:

    ```ts title="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 });
    }
    ```

    | 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 [#deliver-the-outbox]

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

    ```bash
    npx better-iam outbox --config better-iam.config.mjs
    ```
  
      ### Migrate the database [#migrate-the-database]

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

    ```bash
    npx better-iam migrate --config better-iam.config.mjs
    ```
  
      ### Create the client [#create-the-client]

    Browser code calls the `auth` methods through the typed client. Mount the HTTP handler first; see
    [HTTP and configuration](/docs/authentication/http).

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

    export const client = createIamClient<typeof iam>();
    ```
  
## Usage [#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 [#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`.

  **Server:**

    ```ts title="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',
    });
    ```
  
  **Client:**

    ```ts title="register.ts"
    import { client } from './lib/iam-client';

    const { verificationRequired } = await client.auth.signUp({
      tenantId,
      email: 'bob@example.com',
      name: 'Bob',
      password: 'a long passphrase of my own',
    });
    if (verificationRequired) showCheckYourInbox();
    ```
  
**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`](/docs/reference/api/bindings#create) or add the
  person to a group.
* Audited as `auth:identity:create`.

### Sign in [#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 })`.

  **Server:**

    ```ts title="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.
    }
    ```
  
  **Client:**

    ```ts title="login.ts"
    import { IamClientError } from 'better-iam/client';
    import { client } from './lib/iam-client';

    try {
      const result = await client.auth.signIn({ tenantId, email: 'alice@example.com', password });
      if ('mfaRequired' in result) return showSecondFactor(result);
      // Signed in: the HTTP handler set the better-iam.session cookie.
    } catch (error) {
      if (error instanceof IamClientError && error.code === 'INVALID_CREDENTIALS') showWrongPassword();
      else if (error instanceof IamClientError && error.code === 'RATE_LIMITED') showRetryIn(error.retryAfterMs);
      else throw error;
    }
    ```
  
**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](/docs/authentication/sessions#sign-in-records-and-alerts).
* 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"](/docs/authentication/mfa#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](/docs/authentication/mfa) covers every way to answer the challenge.

### Verify the email address [#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](/docs/authentication/passwordless), or confirm an
email change. `auth.requestEmailVerification` sends a new link; `auth.verifyEmail` redeems the token on the page the
link opens.

  **Server:**

    ```ts title="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 });
    ```
  
  **Client:**

    ```ts title="verify-email.ts"
    import { client } from './lib/iam-client';

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

    // On the page the link opens:
    const params = new URLSearchParams(location.search);
    await client.auth.verifyEmail({ tenantId: params.get('tenant')!, token: params.get('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 [#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.

  **Server:**

    ```ts title="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 });
    ```
  
  **Client:**

    ```ts title="reset-password.ts"
    import { client } from './lib/iam-client';

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

    // On the page the email links to:
    await client.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](/docs/authentication/recovery#resetting-on-someones-behalf).

### Change the password [#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.

  **Server:**

    ```ts title="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 });
    }
    ```
  
  **Client:**

    ```ts title="change-password.ts"
    import { client } from './lib/iam-client';

    await client.auth.changePassword({ currentPassword, password: newPassword });
    // Every session ended, including this one: send the person to the login page.
    ```
  
**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 [#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.

  **Server:**

    ```ts title="app/api/reauthenticate.ts"
    import { iam } from './iam';

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

    ```ts title="reauthenticate.ts"
    import { client } from './lib/iam-client';

    const result = await client.auth.reauthenticate({ password });
    ```
  
**Route:** `POST /auth/reauthenticate`

See [recent authentication](/docs/authentication/sessions#recent-authentication).

### Create people with a password [#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.

```ts title="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 [#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 [#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.

```ts title="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 [#organization-rules]

Each organization tightens the baseline with its [tenant policy](/docs/authentication/tenant-policy#password-rules),
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.

```ts title="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 [#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 [#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](/docs/authentication/passwordless), a [passkey](/docs/authentication/passkeys), or
   [federation](/docs/federation/oauth-sign-in).

The [migration guides](/docs/guides/migration) walk through this for specific providers.

## Self-registration and address ownership [#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](/docs/authentication/passwordless),
  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 [#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](/docs/authentication/http#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](/docs/authentication/tenant-policy#policy-fields).

## Schema [#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](/docs/authentication/sessions).                |

## Errors [#errors]

| Code                                                                              | When                                                                                                                  |
| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| [`INVALID_CREDENTIALS`](/docs/reference/errors#invalid_credentials)               | Wrong password, unknown address, no password, or inactive account (always indistinguishable).                         |
| [`EMAIL_UNVERIFIED`](/docs/reference/errors#email_unverified)                     | Verification is required and the address is not verified yet.                                                         |
| [`PASSWORD_EXPIRED`](/docs/reference/errors#password_expired)                     | The password is older than the tenant's `passwordMaxAgeDays`; reset it.                                               |
| [`WEAK_PASSWORD`](/docs/reference/errors#weak_password)                           | The password breaks a length, variety, personal-information, common-password, or custom rule.                         |
| [`BREACHED_PASSWORD`](/docs/reference/errors#breached_password)                   | `passwordPolicy.isBreached` found the password in a breach corpus.                                                    |
| [`PASSWORD_REUSED`](/docs/reference/errors#password_reused)                       | The password matches one of the last `passwordHistory` passwords.                                                     |
| [`PASSWORD_CHECK_UNAVAILABLE`](/docs/reference/errors#password_check_unavailable) | A fail-closed breach check could not reach its service.                                                               |
| [`IDENTITY_EXISTS`](/docs/reference/errors#identity_exists)                       | Sign-up or creation with an address already used in the tenant.                                                       |
| [`FEATURE_DISABLED`](/docs/reference/errors#feature_disabled)                     | Sign-up without `signUpEnabled`, any password flow with `emailPassword: false`, or an email flow without `sendEmail`. |
| [`FORBIDDEN`](/docs/reference/errors#forbidden)                                   | Sign-up in the root tenant.                                                                                           |
| [`METHOD_NOT_ALLOWED`](/docs/reference/errors#method_not_allowed)                 | The tenant's `allowedMethods` excludes `password`.                                                                    |
| [`INVALID_CHALLENGE`](/docs/reference/errors#invalid_challenge)                   | An emailed token is wrong, used, expired, or was sent to an address that has since changed.                           |
| [`RECENT_AUTH_REQUIRED`](/docs/reference/errors#recent_auth_required)             | `changePassword` from a session that is not recent.                                                                   |
| [`RATE_LIMITED`](/docs/reference/errors#rate_limited)                             | Too many attempts; the error carries `retryAfterMs`.                                                                  |
| [`LIMIT_EXCEEDED`](/docs/reference/errors#limit_exceeded)                         | The tenant reached its member limit.                                                                                  |
| [`TENANT_UNAVAILABLE`](/docs/reference/errors#tenant_unavailable)                 | The tenant or one of its ancestors is suspended or missing.                                                           |

## Next steps [#next-steps]

  - [Multi-factor authentication](/docs/authentication/mfa): Answer the MFA challenge a password sign-in can return.

  - [Verification and recovery](/docs/authentication/recovery): Email changes, phone numbers, lost second factors, and lockouts.

  - [Tenant policy](/docs/authentication/tenant-policy): Per-organization password rules, allowed methods, and session limits.

  - [Sessions](/docs/authentication/sessions): What a sign-in issues, and how sessions end.
