BetterIAM
Authentication

Authentication

How people and services prove who they are in Better IAM, how a sign-in flows from first factor to session, and where each method is configured.

@better-iam/auth@better-iam/server@better-iam/clientauthentication.mdsecurity.mdbase.tstypes.ts

Before your application can decide what someone may do, it has to know who they are. Authentication covers how people and services prove their identity, what the resulting session carries, and which knobs the deployment and each organization control.

Every sign-in happens in a tenant, an organization's isolated account: the same email in two tenants is two separate identities with separate credentials, factors, and sessions. The security model explains the guarantees; these pages explain the flows.

Methods at a glance

Different people and situations call for different proofs: a password for most people, a passkey for phishing-resistant sign-in, a company identity provider for enterprise customers, and API keys for machines. You enable the methods your product needs, and each organization can narrow the list.

MethodCallsSession methodNotes
Passwordauth.signIn({ tenantId, email, password })passwordArgon2id; tenant password rules apply on creation, reset, and change.
Magic link or emailed codeauth.startPasswordless, then auth.finishPasswordlesspasswordless-emailNeeds passwordlessEmail and sendEmail. Never links accounts by matching an address.
SMS codeauth.startPasswordless, then auth.finishPasswordlesspasswordless-smsNeeds passwordlessSms and sendSms, and a verified phone number.
Passkeyauth.beginPasskeyAuthentication, then auth.finishPasskeyAuthenticationpasskeyDiscoverable passkeys sign in without an email (browser autofill). Satisfies MFA.
Federated (OAuth/OIDC, SAML)The protocol packages complete the ceremony and call protocolHost.completeAuthenticationfederatedmapAttributes stores provider attributes on the identity.
API key (service accounts)Authorization: Bearer <key>noneIssued with credentials.create; expiring, rotatable, labeled, with lastUsedAt.
Assumed roleroles.assume({ tenantId, trustId })nonePlatform-controlled trust; sessions are short and cannot chain.
Impersonation ("view as")identities.impersonateimpersonationAn administrator's session as a member; opt-in per tenant, restricted, and fully attributed.

The session's method reaches policies as principal.authMethod, so a policy can, for example, require a passkey for a sensitive action. Sign-in methods covers each method in detail.

How a sign-in works

Whatever the method, a sign-in follows the same shape, so your login UI only has to handle two outcomes. The first factor (a password, a code, a passkey, or a federated assertion) ends either with a session, or with a challenge that asks for a second factor.

Every sign-in returns one of two shapes:

SignInResult
type SignInResult =
  | { token: string; session: SafeSession }
  | {
      mfaRequired: true;
      challenge: string; // single-use, valid for five minutes
      enrollmentRequired: boolean; // true when no authenticator is enrolled yet
      emailCodeAvailable?: boolean; // requestMfaCode may email a one-time code
      passkeyAvailable?: boolean; // a registered passkey may satisfy the challenge
    };

A typical login page first finds the tenant with tenants.lookup (from an organization alias), then calls auth.signIn, which checks the password and returns one of those two shapes. Through the HTTP handler, a returned session is also set as a cookie, so the page only needs to navigate on.

login.ts
import { createIamClient, IamClientError } from 'better-iam/client';
import type { iam } from './iam';

const client = createIamClient<typeof iam>();

const { tenantId } = await client.tenants.lookup({ slug: 'acme' });
try {
  const result = await client.auth.signIn({ tenantId, email, password });
  if ('mfaRequired' in result) {
    // Show the second-factor step; see the MFA guide.
    return goToSecondFactor(tenantId, result);
  }
  // Signed in: the handler set the session cookie.
} catch (error) {
  if (error instanceof IamClientError && error.code === 'RATE_LIMITED') {
    showRetryIn(error.retryAfterMs);
  } else throw error;
}

What every sign-in checks

In order, before any session is issued:

  1. Network blocks refuse a blocked client address with IP_BLOCKED, before rate limits or credentials are examined.
  2. Rate limits count the attempt against the client IP (when ipAttempts is set) and the account, and refuse with RATE_LIMITED.
  3. The tenant and every ancestor must be active (TENANT_UNAVAILABLE).
  4. The method must be in the tenant's allowedMethods (METHOD_NOT_ALLOWED). This happens before any credential is examined, so a rejected method never reveals whether a password was right.
  5. The credential is verified. Unknown addresses perform the same password-hash work as real ones and return the same INVALID_CREDENTIALS, so responses do not reveal which accounts exist.
  6. Email verification is required when requireEmailVerification is on (EMAIL_UNVERIFIED), and an expired password is refused with PASSWORD_EXPIRED only after it was verified.
  7. MFA, when required, returns a challenge instead of a session.
  8. Session issuance re-checks the tenant's IP allowlist (IP_NOT_ALLOWED) and blocks, applies the tenant's session lifetime, ends the oldest session beyond maxSessions, records the client, and may queue a new-sign-in notice.

After issuance, the session is re-validated on every use. See sessions.

When MFA is required

A password alone falls to phishing, reuse, and guessing. Multi-factor authentication (MFA) adds a second proof, such as an authenticator code or a passkey. MFA is required:

  • for root administrators, always;
  • for anyone with an authenticator enrolled;
  • when the tenant policy sets requireMfa (or requireMfaForOwners, for owners);
  • when the deployment's authentication.requireMfa(tenant, identity) callback returns true.

The requirement also applies to federated sign-in and to sessions that already exist: requiring MFA in a tenant locks out sessions that did not complete it on their next use. See multi-factor authentication.

Recent authentication

A session can live for days, and a laptop left unlocked for a minute should not let someone change the password or remove the second factor. So sensitive operations (password, email, factor, device, session, and ownership changes, impersonation, and policy changes) require recent authentication: the session must have been established within recentAuthenticationMs (five minutes by default). Otherwise they fail with RECENT_AUTH_REQUIRED, and the person confirms their password with auth.reauthenticate({ password }), which issues a fresh session. See sessions.

In this section

Was this page helpful?

Last updated on

On this page