# Tenant authentication policy (/docs/authentication/tenant-policy)

> Per-organization sign-in rules: required MFA, allowed methods, password rules, session limits, trusted devices, IP allowlists and blocks, and session binding.



Different customers need different sign-in rules. A bank may insist on MFA, passkeys only, and office networks;
a small team may want nothing beyond the defaults. The **tenant authentication policy** lets each organization (a
tenant) set its own rules without you shipping per-customer configuration.

The policy can only **tighten** what the deployment allows. You decide in `authentication` which methods exist
and how long sessions may last at most; a tenant can require more and allow less, never the reverse.

## Setup [#setup]

      ### Set the deployment's limits [#set-the-deployments-limits]

    The deployment options are the ceiling every tenant policy works under. Optionally stamp a default policy on every
    new organization with `tenantDefaults.authPolicy`, validated at construction and applied by `tenants.create`:

    ```ts title="iam.ts"
    import { betterIam } from 'better-iam';
    import { sqliteAdapter } from 'better-iam/adapter-sqlite';

    export const iam = betterIam({
      database: sqliteAdapter({ filename: './iam.db' }),
      secret: process.env.BETTER_IAM_SECRET,
      baseURL: process.env.BETTER_IAM_BASE_URL,
      authentication: {
        passwordlessEmail: true,
        passkeys: { rpID: 'example.com' },
        sessionLifetimeMs: 7 * 24 * 60 * 60_000,
      },
      tenantDefaults: {
        authPolicy: { requireMfaForOwners: true, mfaEmailCodes: true },
      },
      http: {
        // Needed for allowedIpRanges, bindSessionsToIp, and network blocks.
        clientInfo: (request) => ({ ip: request.headers.get('x-real-ip') ?? undefined }),
      },
    });
    ```
  
      ### Migrate [#migrate]

    The policy is stored on the tenant record and blocks in `authBlocks`; `migrate` creates both:

    ```bash
    npx better-iam migrate --config better-iam.config.mjs
    ```
  
      ### Give organizations a settings page [#give-organizations-a-settings-page]

    Owners hold every `iam:*` action in their tenant, so they can call `tenants.setAuthPolicy` from your settings page
    through the browser client. Grant `iam:tenants:update` (and `iam:security:manage` for network blocks) to anyone
    else who should edit it.

    ```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]

### Set a policy [#set-a-policy]

`tenants.setAuthPolicy` replaces a tenant's whole policy. It needs `iam:tenants:update` and recent authentication.
Pass `null` to clear the policy.

  **Server:**

    ```ts
    import { iam } from './iam';

    await iam.api.tenants.setAuthPolicy(ownerCredential, {
      tenantId,
      authPolicy: {
        requireMfa: true,
        allowedMethods: ['passkey', 'password'],
        sessionIdleTimeoutMs: 30 * 60_000,
        maxSessions: 5,
        minPasswordLength: 14,
        passwordHistory: 5,
        trustedDeviceDays: 7,
        notifyNewSignIn: true,
      },
    });
    ```
  
  **Client:**

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

    await client.tenants.setAuthPolicy({
      tenantId,
      authPolicy: { requireMfa: true, allowedMethods: ['passkey', 'password'] },
    });
    ```
  
**Route:** `POST /api/iam/tenants/setAuthPolicy`

Every field is optional, unknown fields are refused, and each value is range-checked (`INVALID_INPUT`). A deleted
tenant cannot be changed (`INVALID_TRANSITION`). Each change is audited as `tenant:auth-policy` with the new
policy, and the call returns the updated tenant.

### Change one field [#change-one-field]

Because `setAuthPolicy` replaces the whole policy, read the current one first. `tenants.get` returns it as
`authPolicy`:

```ts
import { iam } from './iam';

const tenant = await iam.api.tenants.get(credential, { tenantId });
await iam.api.tenants.setAuthPolicy(credential, {
  tenantId,
  authPolicy: { ...tenant.authPolicy, maxSessions: 3 },
});
```

**Route:** `POST /api/iam/tenants/get`

### Requiring MFA [#requiring-mfa]

The problem MFA solves is a stolen or guessed password. Two switches cover the common rollouts:

* `requireMfaForOwners: true` protects the owners first, the people who can change the policy and grant access.
* `requireMfa: true` then covers everyone. People without a factor are asked to enroll an authenticator on their
  next sign-in, and sessions that did not pass MFA stop working immediately.

To avoid forcing every member to install an authenticator app, add `mfaEmailCodes: true`: people with nothing
enrolled can then answer the challenge with a code sent to their verified email address. See
[multi-factor authentication](/docs/authentication/mfa).

### Restricting sign-in methods [#restricting-sign-in-methods]

`allowedMethods` lets an organization accept only the methods it trusts, for example `['federated']` to force
everyone through the company's identity provider, or `['passkey']` for phishing-resistant sign-in only. The check
runs before any credential is examined, so a rejected method never reveals whether a password was right.

Impersonation is an administrative action, not a sign-in method, so it is not listed; the policy's
`allowImpersonation` controls it. `domains.discover` reports a tenant's `allowedMethods` and MFA requirement, so a
login page can show the right buttons as soon as someone types their work email.

### Password rules [#password-rules]

Deployment-wide screening (common passwords, a breach corpus, a custom check) applies to everyone; see
[password screening](/docs/authentication/sign-in-methods#password-screening). A tenant adds its own rules on
top, and every rule applies wherever a password is set: creation, invitations, bulk onboarding, reset, and change.

* **Length and variety.** `minPasswordLength` (12 to 128) and `passwordMinClasses` (2 to 4 of lowercase,
  uppercase, digits, and symbols). Failures return `WEAK_PASSWORD`.
* **No personal information.** `passwordRejectPersonalInfo` refuses a password that contains the email local part
  or a name word of four or more characters (`WEAK_PASSWORD`).
* **History.** `passwordHistory` refuses the last 1 to 24 passwords, counting the current one
  (`PASSWORD_REUSED`). A new password is compared with Argon2 against the current hash and as many earlier ones as
  the setting asks for. Up to 24 earlier hashes are retained, and they are deleted with the identity.
* **Maximum age.** `passwordMaxAgeDays` expires a password that many days after it was set (or after the
  identity was created, for older records). An expired password is refused with `PASSWORD_EXPIRED` only after it
  has been verified, so expiry never reveals whether a guess was right. The person recovers through
  [password reset](/docs/authentication/recovery#password-reset).

### Sessions and devices [#sessions-and-devices]

Shorter sessions limit how long a stolen cookie or an unattended laptop stays useful.

* `sessionLifetimeMs` and `sessionIdleTimeoutMs` shorten the deployment's absolute lifetime and idle timeout.
* `maxSessions` caps how many sessions one person may hold at once; the oldest ends when a new one is issued.
* `trustedDeviceDays` shortens, or with `0` disables, "remember this device".
* `notifyNewSignIn` emails people about sessions from clients they have not used before.

See [sessions](/docs/authentication/sessions).

### Network restrictions [#network-restrictions]

Some organizations must keep access inside their own networks, and every organization needs a way to shut out an
attacker's address during an incident. Three tools cover this. All of them judge the client IP that Better IAM
recorded, so they need `http.clientInfo` configured behind a proxy you control; a sign-in or request without a
recorded IP (direct API use, or the handler without `clientInfo`) is not judged. See
[client details](/docs/authentication/http#client-details).

#### IP allowlist [#ip-allowlist]

`allowedIpRanges` lists the networks (IPv4 or IPv6 addresses, or CIDR blocks) the tenant's people may sign in from.

```ts
authPolicy: { allowedIpRanges: ['203.0.113.0/24', '2001:db8::/32'] },
```

* Issuing a session, including an impersonation session, from outside every range fails with `IP_NOT_ALLOWED`.
* A session whose recorded IP falls outside the ranges stops working at its next use, so tightening the list cuts
  off existing sessions.
* An assumed-role session is judged against the allowlist of the organization it acts in.
* For your own organization, the list must include the address your session was issued from and the address the
  request comes from; otherwise the change is refused (`INVALID_INPUT`) so you cannot lock yourself out.
* Refusals show up as `denied` spans with code `IP_NOT_ALLOWED`.

#### Binding sessions to their network [#binding-sessions-to-their-network]

`bindSessionsToIp: true` makes a stolen session cookie useless from anywhere else. A user session is then accepted
only from the address it was issued from.

* A use from another address is refused with `SESSION_NETWORK_MISMATCH` (401) and recorded in the person's trail
  as `auth:session:mismatch`, with both addresses in the metadata.
* The person simply signs in again from the new network, while the old session keeps working from the old one.
* Sessions and requests without a recorded address are not judged.
* The browser client's `onUnauthenticated` hook fires for this code, so people land on the login page.

#### Network blocks [#network-blocks]

Network blocks are the incident-response counterpart of the allowlist: when an address keeps guessing passwords,
block it. Unlike the other fields on this page, blocks are managed with the `security` group rather than the
policy.

  **Server:**

    ```ts
    import { iam } from './iam';

    const block = await iam.api.security.blockNetwork(adminCredential, {
      tenantId,
      network: '198.51.100.23', // an address or a CIDR block
      reason: 'Password spraying against several members',
      durationMs: 24 * 60 * 60_000, // optional: one minute to a year; omit to block until lifted
    });
    const blocks = await iam.api.security.listBlocks(adminCredential, { tenantId });
    await iam.api.security.unblockNetwork(adminCredential, { tenantId, blockId: block.id });
    ```
  
  **Client:**

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

    const block = await client.security.blockNetwork({
      tenantId,
      network: '198.51.100.0/24',
      reason: 'Credential stuffing',
    });
    await client.security.unblockNetwork({ tenantId, blockId: block.id });
    ```
  
**Routes:** `POST /api/iam/security/blockNetwork`, `POST /api/iam/security/listBlocks`,
`POST /api/iam/security/unblockNetwork`

* `security.blockNetwork` refuses every authentication flow and every live session whose recorded IP falls in
  the network, and every API key or assumed-role token presented from it, with `IP_BLOCKED`. It needs
  `iam:security:manage` and recent authentication, and is audited as `security:network-block`.
* The check runs **before** rate limits and credentials, so a blocked address cannot count against anyone's
  attempts.
* Blocking a network that contains your own address is refused, so nobody locks themselves out. Blocking the same
  network again renews the block.
* `security.unblockNetwork({ tenantId, blockId })` lifts a block early (audited as `security:network-unblock`).
* `security.listBlocks({ tenantId })` shows the tenant's blocks, newest first, each with `active` telling whether
  it has lapsed. It needs `iam:security:read`.
* An organization's blocks apply to itself. Root administrators can set `platform: true` on the root tenant to
  block a network for every tenant.
* Each process reuses a tenant's block list for five seconds, so a change reaches other processes within that
  time. Lapsed blocks are deleted by the retention worker.

The console's administration panel can block a source address platform-wide for a day in one click from its
sign-in failures page, and organization owners manage their own blocks on the settings page.

### Impersonation [#impersonation]

`allowImpersonation` is off by default. Turning it on lets administrators who hold `iam:identities:impersonate`
open restricted, fully audited "view as" sessions for members. See
[impersonation](/docs/authentication/impersonation).

## How the policy is enforced [#how-the-policy-is-enforced]

The authentication service consults the policy at three points:

1. **When a sign-in starts:** the method must be allowed, and the tenant's `maxAttempts` caps the rate limit.
2. **When a session is issued:** MFA requirements, the IP allowlist, the session lifetime, `maxSessions`, and new
   sign-in notices apply.
3. **Every time a session is used:** the idle timeout, the MFA requirement, the IP allowlist, and IP binding are
   checked again.

Because of the third point, existing sessions follow a new policy on their next use. Requiring MFA locks out
sessions that did not complete it right away, and a person cannot disable their authenticator while the tenant
requires MFA.

The policy only ever tightens:

* Session lifetimes and attempt limits take the smaller of the tenant's and the deployment's values.
* `requireMfa` **adds to** the deployment's `authentication.requireMfa(tenant, identity)` callback; it never
  replaces it.
* Root administrators always need MFA, whatever the policy says.

## Options [#options]

### Policy fields [#policy-fields]

These are the fields of `authPolicy` (the `TenantAuthPolicy` type from `@better-iam/core`). Every field is optional;
an unset field leaves the deployment's behavior unchanged.

| Field                        | Type                                                                                       | Default                          | Description                                                                                                                                                       |
| ---------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `requireMfa`                 | `boolean`                                                                                  | `false`                          | Every person must complete MFA. People without a factor enroll on their next sign-in, and existing sessions that did not pass MFA stop working at their next use. |
| `requireMfaForOwners`        | `boolean`                                                                                  | `false`                          | Only owners must complete MFA.                                                                                                                                    |
| `allowedMethods`             | `('password' \| 'passwordless-email' \| 'passwordless-sms' \| 'passkey' \| 'federated')[]` | every enabled method             | The sign-in methods the tenant accepts; at least one. Others are refused with `METHOD_NOT_ALLOWED` before any credential is examined.                             |
| `sessionLifetimeMs`          | `number`                                                                                   | deployment value                 | A shorter absolute session lifetime, one minute to 30 days, capped by the deployment's.                                                                           |
| `sessionIdleTimeoutMs`       | `number`                                                                                   | deployment value                 | A shorter idle timeout, one minute to 30 days and not above `sessionLifetimeMs`.                                                                                  |
| `maxSessions`                | `number`                                                                                   | unlimited                        | Concurrent user sessions per person, 1 to 100. Issuing one more ends the oldest.                                                                                  |
| `maxAttempts`                | `number`                                                                                   | deployment limits                | Authentication attempts per rate-limit window, 1 to 100,000. Never raises the deployment's limits.                                                                |
| `minPasswordLength`          | `number`                                                                                   | `12`                             | Minimum password length, 12 to 128.                                                                                                                               |
| `passwordMinClasses`         | `number`                                                                                   | none                             | Character classes (lowercase, uppercase, digits, symbols) a new password must mix, 2 to 4.                                                                        |
| `passwordRejectPersonalInfo` | `boolean`                                                                                  | `false`                          | Refuse passwords containing the email local part or a name word of four or more characters.                                                                       |
| `passwordHistory`            | `number`                                                                                   | none                             | Refuse reuse of this many most recent passwords, counting the current one, 1 to 24 (`PASSWORD_REUSED`).                                                           |
| `passwordMaxAgeDays`         | `number`                                                                                   | none                             | Passwords older than this stop signing in until reset (`PASSWORD_EXPIRED`), 1 to 3,650.                                                                           |
| `trustedDeviceDays`          | `number`                                                                                   | deployment value                 | How long "remember this device" may skip MFA, 0 to 365 days; `0` turns it off.                                                                                    |
| `mfaEmailCodes`              | `boolean`                                                                                  | deployment value                 | Offer one-time codes by email to people without an authenticator. Root administrators always need an authenticator.                                               |
| `allowImpersonation`         | `boolean`                                                                                  | `false`                          | Let holders of `iam:identities:impersonate` open "view as" sessions for members.                                                                                  |
| `allowedIpRanges`            | `string[]`                                                                                 | none                             | IPv4 or IPv6 addresses or CIDR blocks that sessions may be issued from and used from; at least one. Needs recorded client IPs.                                    |
| `notifyNewSignIn`            | `boolean`                                                                                  | deployment `signInNotifications` | Email people when a session starts from a client none of their live sessions or remembered devices has used.                                                      |
| `bindSessionsToIp`           | `boolean`                                                                                  | `false`                          | A session works only from the IP address it was issued from; elsewhere `SESSION_NETWORK_MISMATCH`. Needs recorded client IPs.                                     |

Related deployment options: `tenantDefaults.authPolicy` (applied by `tenants.create`), `http.clientInfo` (records
client IPs), and every `authentication` limit listed in [HTTP and configuration](/docs/authentication/http#options).

## Schema [#schema]

| Collection        | Key fields                                                                               | Notes                                                                             |
| ----------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `tenants`         | `authPolicy`                                                                             | The policy is stored on the tenant record.                                        |
| `authBlocks`      | `id`, `tenantId`, `network`, `reason`, `createdAt`, `createdBy`, `expiresAt`, `platform` | Network blocks. `platform: true` blocks on the root tenant apply to every tenant. |
| `passwordHistory` | `identityId`, `hash`, `createdAt`                                                        | Earlier password hashes for `passwordHistory`, up to 24 per identity.             |

## Errors [#errors]

| Code                                                                                                                                                                                | When                                                                                         |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| [`INVALID_INPUT`](/docs/reference/errors#invalid_input)                                                                                                                             | An unknown field, a value out of range, or an allowlist that would exclude your own address. |
| [`INVALID_TRANSITION`](/docs/reference/errors#invalid_transition)                                                                                                                   | The tenant is deleted.                                                                       |
| [`RECENT_AUTH_REQUIRED`](/docs/reference/errors#recent_auth_required)                                                                                                               | Changing the policy or blocks from a session that is not recent.                             |
| [`ACCESS_DENIED`](/docs/reference/errors#access_denied)                                                                                                                             | The caller lacks `iam:tenants:update`, `iam:security:manage`, or `iam:security:read`.        |
| [`METHOD_NOT_ALLOWED`](/docs/reference/errors#method_not_allowed)                                                                                                                   | A sign-in uses a method outside `allowedMethods`.                                            |
| [`IP_NOT_ALLOWED`](/docs/reference/errors#ip_not_allowed)                                                                                                                           | A session is issued or used from outside `allowedIpRanges`.                                  |
| [`IP_BLOCKED`](/docs/reference/errors#ip_blocked)                                                                                                                                   | A sign-in, session, API key, or role token comes from a blocked network.                     |
| [`SESSION_NETWORK_MISMATCH`](/docs/reference/errors#session_network_mismatch)                                                                                                       | A bound session is presented from another address.                                           |
| [`WEAK_PASSWORD`](/docs/reference/errors#weak_password), [`PASSWORD_REUSED`](/docs/reference/errors#password_reused), [`PASSWORD_EXPIRED`](/docs/reference/errors#password_expired) | A password fails the tenant's rules.                                                         |
| [`MFA_REQUIRED`](/docs/reference/errors#mfa_required)                                                                                                                               | A session without MFA is used after the tenant started requiring it.                         |

## Next steps [#next-steps]

  - [Multi-factor authentication](/docs/authentication/mfa): Authenticators, emailed codes, and remembered devices.

  - [Sessions](/docs/authentication/sessions): Lifetimes, caps, and revalidation.

  - [Impersonation](/docs/authentication/impersonation): What `allowImpersonation` turns on.

  - [HTTP and configuration](/docs/authentication/http): Client details behind a proxy.
