BetterIAM

Rate limits and lockouts

The attempt limits Better IAM enforces on sign-in, recovery, MFA, and other credential-checking flows, how lockouts and unlocking work, and which other limits and quotas exist.

Better IAM limits how fast anyone can guess credentials: passwords, emailed and SMS codes, MFA and recovery codes, reset and invitation tokens. Every flow that checks such a secret, or sends one, counts the attempt in a durable counter before it looks at the secret, and refuses with RATE_LIMITED (HTTP 429) once the counter is exhausted. That counter running out is what this documentation calls a lockout: it is temporary, ends when the window passes, and an administrator can clear it early.

Better IAM has no general request-rate limiter. Ordinary authenticated API calls (roles.list, authorize, and so on) are not throttled, and public lookups such as tenants.lookup are not counted. Apply request and body limits at your ingress or API gateway. To meter and cap use of your own product API per customer, use API quotas.

MechanismWhat it protectsConfigured by
Attempt limitsSign-in, sign-up, recovery, verification, MFA, passwordless, passkeysauthentication.rateLimits, tenant maxAttempts
Per-IP limitPassword spraying and credential stuffing across many accountsauthentication.rateLimits.ipAttempts
Server flow budgetsAgent delegation, guest redemption, privacy intake, SSH, device enrollment, web identity, step-upFixed, or the feature's own option
Network blocksAn address that keeps attackingsecurity.blockNetwork
Fixed capsFloods of requests that waiting does not fixBuilt in
API quotasYour own product API, per key, agent, person, or organizationquotas API group

Attempt limits

Each counted flow builds a counter key from the tenant and a subject: the email or phone number being signed in, the identity, or a hash of the token being redeemed. The key is stored as a SHA-256 hash, so counters never hold raw addresses. Every attempt counts, successful or not, and a counter resets when its window ends.

There are three tiers:

TierAllowance per windowFlows
Ordinaryattempts (default 10)auth.signIn, auth.reauthenticate, auth.changePassword, auth.beginPasskeyAuthentication with an email, and redeeming emailed tokens (auth.verifyEmail, auth.resetPassword, auth.confirmEmailChange)
SensitivesensitiveAttempts (default 5)auth.signUp, every call that sends an email or SMS (requestEmailVerification, requestPasswordReset, requestEmailChange, startPasswordless, startPhoneVerification, requestMfaCode), checking passwordless and phone codes, auth.confirmMfa (enrollment), every step that answers an MFA challenge (verifyMfa, recoverMfa, beginPasskeyMfa, finishPasskeyMfa), and finishing a passkey sign-in
Generousattempts × 10Passkey discovery without an email (beginPasskeyAuthentication with no address), counted per client, because login pages start one on every visit
  • Second factors count twice. An MFA step counts once against the sign-in challenge and once against the person. Every correct password mints a fresh challenge, so without the per-person counter, whoever holds the password would get attempts × sensitiveAttempts code guesses per window.
  • Counted outside the transaction. The attempt is committed before the flow's own transaction starts, so a refusal or a rollback never un-counts it.
  • Tenants only tighten. A tenant's authentication policy may set maxAttempts (1 to 100,000), which lowers the per-subject allowance of every tier for that tenant. It never raises the deployment's limits.

Per-IP limit

Per-account counters do not stop an attacker who tries one password against thousands of accounts. With rateLimits.ipAttempts set, every authentication flow of a tenant also counts against one counter per client address, checked before the per-subject counter.

  • IPv6 clients are counted per /64 network, and IPv4-mapped IPv6 addresses as their IPv4 form, so rotating addresses earns no fresh counter.
  • It needs a recorded client IP, from http.clientInfo or withClient. Direct server-side API calls carry no IP and are never judged by it.
  • Size it for the largest office or carrier NAT your customers sign in from. 0 (the default) turns it off.

Server flow budgets

Several server features that accept a secret or a costly request reuse the same limiter with a budget of their own. Each budget uses the configured window (rateLimits.windowMs) and limiter, and refusals are RATE_LIMITED:

FlowCounterAllowance per window
delegations.request (an agent asks a person for access)Per agentSensitive tier
delegations.handoffPer delegationSensitive tier
delegations.issueTokenPer delegation120
delegations.requestConfirmationPer delegationOrdinary tier
sts.getSessionToken with an mfaCode (step-up)Per identitySensitive tier
sts.assumeRoleWithWebIdentityPer trust and client IP; per trust overallsts.webIdentity.maxExchangesPerWindow (default 600); ten times that per trust
devices.enrollPer identity30
guests.redeemPer client IP; per tenant; per invitation token20; 500; 10
privacy.submitPublicPer tenant; per email address500; 3
privacy.confirmPublicPer request10
ssh.enrollHost, ssh.syncHostPer host10; 120
ssh.issueCertificatePer identityssh.issuanceLimit (default 60)
Shared Signals push endpointPer stream source3000

Budgets with a fixed number ignore the tenant's maxAttempts, so automation never loosens or tightens human sign-in limits. The flows that run the network steps also apply network blocks and the per-IP counter first.

Setup

Configure the limits

The defaults suit most deployments. Change them under authentication.rateLimits:

iam.ts
import { betterIam } from 'better-iam';
import { postgresAdapter } from 'better-iam/adapter-postgres';

export const iam = betterIam({
  database: postgresAdapter({ connectionString: process.env.DATABASE_URL! }),
  secret: process.env.BETTER_IAM_SECRET!,
  baseURL: 'https://app.example.com',
  authentication: {
    rateLimits: {
      attempts: 10, // ordinary flows
      sensitiveAttempts: 5, // codes, deliveries, MFA
      windowMs: 15 * 60_000,
      ipAttempts: 100, // per client IP across a tenant's flows; 0 turns it off
    },
  },
  http: {
    // Record the client IP behind your trusted proxy, so ipAttempts and network blocks can judge it.
    clientInfo: (request) => ({
      ip: request.headers.get('x-real-ip') ?? undefined,
      userAgent: request.headers.get('user-agent') ?? undefined,
    }),
  },
});

Migrate

The default limiter keeps its counters in the IAM database (authRateLimits), created by migrate like every other collection:

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

Let the client wait and retry

The browser client exposes the server's wait as IamClientError.retryAfterMs. With retryRateLimited, it retries once by itself when the wait is short (5 seconds by default):

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

export const client = createIamClient<typeof iam>({
  baseURL: 'https://app.example.com',
  retryRateLimited: { maxWaitMs: 3_000 },
});

Usage

Handle a refusal

A refused attempt throws RATE_LIMITED with retryAfterMs, the window length: the counter resets at most that far away. The HTTP handler adds a Retry-After header in seconds and puts retryAfterMs in the JSON error body.

app/sign-in.ts
import { IamError } from 'better-iam';
import { iam } from './iam';

try {
  await iam.api.auth.signIn({ tenantId, email, password });
} catch (error) {
  if (error instanceof IamError && error.code === 'RATE_LIMITED') {
    const { retryAfterMs } = error as IamError & { retryAfterMs?: number };
    return { error: `Too many attempts. Try again in ${Math.ceil((retryAfterMs ?? 0) / 60_000)} minutes.` };
  }
  throw error;
}

Route: POST /auth/signIn (every counted flow answers 429 the same way)

Tell people when they may try again, never which counter ran out: the message is the same for an unknown address and a real one.

Unlock a person

When a real person has locked themselves out, an administrator clears their counters instead of making them wait. identities.unlock needs iam:identities:update on the identity and recent authentication, and is audited as identity:unlock.

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

Route: POST /identities/unlock

  • It clears the counters for the person's email and phone (sign-up, sign-in, verification, reset, passwordless, passkey) and identity (password change, reauthentication, email and phone changes, MFA enrollment, and the per-person second-factor counters).
  • It never clears a network's per-IP counter, and it does not lift network blocks.
  • A custom limiter without reset makes it a no-op that returns supported: false.

Tighten limits for one tenant

Set maxAttempts in the tenant's authentication policy. tenants.setAuthPolicy replaces the whole policy, so read the current one first and keep its other fields:

const tenant = await iam.api.tenants.get(ownerCredential, { tenantId });
await iam.api.tenants.setAuthPolicy(ownerCredential, {
  tenantId,
  authPolicy: { ...tenant.authPolicy, maxAttempts: 5 },
});

Route: POST /tenants/setAuthPolicy

See tenant policy for the other fields.

Block a network

When one address keeps attacking, block it. A blocked network is refused with IP_BLOCKED before any credential is examined or any counter moves.

await iam.api.security.blockNetwork(adminCredential, {
  tenantId,
  network: '198.51.100.0/24',
  reason: 'Password spraying against several members',
  durationMs: 24 * 60 * 60_000,
});

Route: POST /security/blockNetwork (also POST /security/unblockNetwork, POST /security/listBlocks)

See network blocks for permissions and platform-wide blocks.

Share counters across instances

The default limiter stores counters in the IAM database, so every instance sharing the database shares the counters, and limits survive restarts. Each attempt costs one short write transaction. To keep counters elsewhere, supply rateLimits.limiter, an object with consume() and optionally reset():

redis-limiter.ts
import type { RateLimiter } from 'better-iam/auth';
import { redis } from './redis'; // your Redis client (for example ioredis)

export const redisLimiter: RateLimiter = {
  async consume({ key, limit, windowMs }) {
    const counter = `iam:rate:${key}`;
    const count = await redis.incr(counter);
    if (count === 1) await redis.pexpire(counter, windowMs); // the window starts with the first attempt
    return count <= limit; // true permits the attempt
  },
  async reset({ key }) {
    await redis.del(`iam:rate:${key}`);
  },
};
iam.ts
authentication: {
  rateLimits: { limiter: redisLimiter },
},
  • consume must count the attempt even when the caller later fails, and return false once limit is reached.
  • Without reset, identities.unlock cannot clear counters.
  • createMemoryRateLimiter({ maxKeys }) from better-iam/auth keeps counters in process memory (at most maxKeys, default 100,000). Use it for tests and single-process deployments only; counters are lost on restart and not shared.
  • createStoreRateLimiter(store) is the default database limiter, exported for composition.

Fixed caps

Some limits are not about guessing, and waiting alone does not lift them:

LimitErrorLift it by
20 pending access requests per requester (accessRequests.create)TOO_MANY_REQUESTS (429)Deciding or cancelling pending requests
A KMS key rotates on demand at most ten times a day (keys.rotate)RATE_LIMITED (429)Waiting for older rotations to leave the 24-hour window
compliance.evaluate runs at most once a minute per tenantRATE_LIMITED (429)Waiting a minute
Plan limits and per-tenant caps on records (members, webhooks, roles, ...)LIMIT_EXCEEDED (409)Raising the limit with tenants.setLimits, or deleting records
Usage plans on your own metersQUOTA_EXCEEDED (429)Waiting for Retry-After, or a larger plan (API quotas)

The LDAP gateway maps RATE_LIMITED on a bind to LDAP unwillingToPerform.

Options

All attempt-limit options live under authentication.rateLimits. Values outside the ranges fail construction with INVALID_CONFIG.

OptionTypeDefaultDescription
attemptsnumber10Ordinary-tier attempts per window, 1 to 100,000. Passkey discovery gets ten times this.
sensitiveAttemptsnumber5Sensitive-tier attempts per window, 1 to 100,000.
windowMsnumber900000 (15 minutes)Window length, 1 second to 24 hours. Also the retryAfterMs of every refusal and the window of server flow budgets.
ipAttemptsnumber0 (off)Attempts per window from one client network across all of a tenant's flows, 0 to 1,000,000.
limiterRateLimiterdatabase limiterWhere counters live: { consume, reset? }.

Related options:

OptionTypeDefaultDescription
Tenant authPolicy.maxAttemptsnumbernoneLowers every per-subject allowance for one tenant, 1 to 100,000. Set with tenants.setAuthPolicy or tenantDefaults.authPolicy.
authentication.failedSignInAlertsnumber0 (off)Emails the person a sign-in-failures notice once this many attempts have failed since their last sign-in.
sts.webIdentity.maxExchangesPerWindownumber600Web-identity exchanges per trust and client per window, 1 to 100,000.
ssh.issuanceLimitnumber60SSH certificates one identity may request per window, 1 to 100,000.

Schema

The default limiter stores one record per live counter in the authRateLimits collection:

FieldTypeDescription
idstringSHA-256 hex of {tenantId}:{subject}; the raw email, phone, or token never appears.
tenantIdstringThe tenant the counter belongs to.
countnumberAttempts counted in the current window.
resetAtnumberWhen the window ends, epoch milliseconds.

Expired counters are deleted opportunistically (at most once a minute per process, in batches of 200) and by iam.purgeDeleted() (CLI purge).

Errors

CodeHTTPWhen
RATE_LIMITED429An attempt counter or a flow budget is exhausted. Carries retryAfterMs and a Retry-After header.
IP_BLOCKED403The client network is blocked for the tenant or the platform. No counter moves.
TOO_MANY_REQUESTS429The requester already has 20 pending access requests.
QUOTA_EXCEEDED429A usage plan's throttle or period limit refused the call.
LIMIT_EXCEEDED409A plan limit or fixed per-tenant cap on records was reached.
INVALID_CONFIGnoneA rateLimits value is out of range, or limiter has no consume().

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page