BetterIAM

Sessions

Database sessions with absolute and idle lifetimes, device metadata, recent authentication, sign-out everywhere, session caps, and sign-in records.

@better-iam/auth@better-iam/server@better-iam/clientauthentication.mdsecurity.mdsessions.tsbase.tstypes.tsindex.tsapi.json

After someone signs in, every later request has to prove it comes from them. That proof is a . In Better IAM a session is a database record, not a self-contained token such as a JWT. The bearer token (or cookie value) is a random secret returned once; only its hash is stored.

Storing sessions costs a database read per request, and buys three things a self-contained token cannot offer:

  • Instant revocation. Signing out everywhere, disabling a person, or blocking a network takes effect on the next request, not when a token expires.
  • Context. Each session records how and where it was established, for device lists and security alerts.
  • Live policy. Each use is re-checked against the current state of the identity, the tenant, and its policy.

If you need signed tokens for other services, see session JWTs; they are minted from a session and do not replace it.

Setup

Choose lifetimes

Sessions work with no configuration. Set the deployment's limits under authentication, and record client details with http.clientInfo so device lists and network rules have an IP to work with:

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: {
    sessionLifetimeMs: 7 * 24 * 60 * 60_000, // absolute lifetime
    sessionIdleTimeoutMs: 12 * 60 * 60_000, // idle timeout
    recentAuthenticationMs: 5 * 60_000, // window for sensitive operations
    failedSignInAlerts: 5, // email after five failed attempts (needs sendEmail)
  },
  http: {
    // Only trust a header your own proxy sets and overwrites.
    clientInfo: (request) => ({
      ip: request.headers.get('x-real-ip') ?? undefined,
      userAgent: request.headers.get('user-agent') ?? undefined,
    }),
  },
});

Migrate

Sessions are stored in the sessions collection that migrate creates:

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

Send people to the login page in one place

The browser client's onUnauthenticated hook fires for exactly UNAUTHENTICATED and SESSION_NETWORK_MISMATCH, the two codes that mean the session can no longer be used:

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

export const client = createIamClient<typeof iam>({
  onUnauthenticated: () => window.location.assign('/login'),
});

Lifetimes

Long sessions are convenient; short ones limit the damage of a stolen cookie or an unattended computer. Every user session balances the two with two clocks:

  • an absolute lifetime: the session ends this long after it was issued, however active it is;
  • an idle timeout: the session ends when nothing has used it for this long.
OptionDefaultRange
authentication.sessionLifetimeMs7 daysone minute to 30 days
authentication.sessionIdleTimeoutMsthe shorter of 24 hours and the lifetimeone minute to the lifetime

A tenant's sessionLifetimeMs and sessionIdleTimeoutMs shorten these for its members but never extend them. Validating a session updates its lastSeenAt at most once a minute, or once per tenth of the idle timeout when that is shorter, so busy clients do not turn every request into a write; idle expiry is accurate to that interval and only ever earlier than configured.

Revalidation on every use

A session that was valid an hour ago may not be valid now: the person may have been disabled, or the organization may have started requiring MFA. So a session is checked again each time it is presented:

  • the session exists and has not passed its absolute lifetime or idle timeout;
  • the identity is an active person in an active tenant with an active ancestry;
  • the email is verified when requireEmailVerification is on (EMAIL_UNVERIFIED);
  • the session passed MFA if the person now requires it (MFA_REQUIRED);
  • for an impersonation session, the administrator's own session and identity are still live;
  • the recorded client address is inside the tenant's allowedIpRanges (IP_NOT_ALLOWED) and not blocked (IP_BLOCKED), and, with bindSessionsToIp, the presenting address matches it (SESSION_NETWORK_MISMATCH).

Provisioning operations and authorization queries repeat these checks on a fresh read inside their own transaction, and there an identity past its scheduled expiresAt is refused as well. A lapsed or revoked session fails with UNAUTHENTICATED (401). The browser client's onUnauthenticated hook fires for exactly UNAUTHENTICATED and SESSION_NETWORK_MISMATCH, so you can send people to the login page in one place.

Usage

Read the current session

auth.getSession returns the signed-in identity, the session, and the limits in force. Calling it also counts as activity, so it doubles as "keep me signed in".

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

const { identity, session, limits } = await client.auth.getSession();
// limits: { lifetimeMs, idleTimeoutMs, idleExpiresAt, now }

Route: POST /api/iam/auth/getSession

Warning before an idle sign-out

Being signed out in the middle of filling a form is frustrating. idleExpiresAt is when the session lapses if nothing touches it again (the call itself just did), and now is the server's clock so you can correct for skew. A client can warn before an idle sign-out and keep the session alive on request by calling getSession again. The console does exactly that: it shows a countdown two minutes ahead with "Stay signed in", and returns to the login page once the session has lapsed.

Device metadata

People recognize their sessions by device ("Chrome on a MacBook, Berlin"), and security features need to know where a session came from. Each session records the sign-in method and the client it was established from:

  • client.userAgent, client.ip, and client.label (a short device name your application derives);
  • captured by the HTTP handler through http.clientInfo(request), or by iam.auth.withClient(info, fn) for sessions you create with direct iam.api.auth.* calls.

Without http.clientInfo, the handler records the User-Agent header only. Values are trimmed and bounded (IP 64, user agent 512, label 128 characters). Client details are informational: nothing about the client is trusted for authorization, and an IP is recorded only when your clientInfo derives it from a proxy header you control. See client details.

iam.auth.withClient(info, fn) runs fn so that every session it issues records info. Use it when your own server code signs people in without going through the HTTP handler:

Direct calls
import { iam } from './iam';

const result = await iam.auth.withClient({ ip, userAgent, label: 'Kiosk 4' }, () =>
  iam.api.auth.signIn({ tenantId, email, password }),
);

Listing and ending sessions

A lost phone, a shared computer, or a suspicious sign-in all call for ending sessions. People manage their own from an account page:

CallRouteWhat it does
auth.listSessions()POST /api/iam/auth/listSessionsLists the person's live sessions with their device details; current marks the one making the call.
auth.revokeSession({ sessionId })POST /api/iam/auth/revokeSessionEnds one of their sessions, such as the one on a lost phone. Needs recent authentication.
auth.revokeOtherSessions()POST /api/iam/auth/revokeOtherSessions"Sign out everywhere else": ends every other session and keeps this one; returns { revoked }. Needs recent authentication.
auth.signOut()POST /api/iam/auth/signOutEnds the current session. Through the HTTP handler it also clears the session cookie.
import { client } from './lib/iam-client';

const sessions = await client.auth.listSessions();
const phone = sessions.find((session) => !session.current && session.client?.label === 'Pixel 8');
if (phone) await client.auth.revokeSession({ sessionId: phone.id });

Administrators handle other people's sessions, for support and incident response:

CallRouteNeedsEffect
identities.listSessionsPOST /api/iam/identities/listSessionsiam:identities:readOne identity's live sessions, without token hashes (device lists, support).
identities.revokeSessionsPOST /api/iam/identities/revokeSessionsiam:identities:update, recent authenticationEnds every session of one identity without disabling it (incident response, a lost device); keepApiKeys: true leaves its API keys working. Audited as identity:revoke-sessions.
tenants.revokeSessionsPOST /api/iam/tenants/revokeSessionsiam:tenants:update, recent authenticationEnds every session in the tenant, including role sessions sourced from it and impersonation sessions opened through an ended session, and forgets the tenant's remembered devices. The caller's own session and devices are kept unless includeSelf. Audited as tenant:revoke-sessions.
Incident response
import { iam } from './iam';

const { revoked } = await iam.api.identities.revokeSessions(adminCredential, { tenantId, identityId });

Ending a session also ends the impersonation sessions an administrator opened through it. Revocation takes effect on the next request in every process, because nothing is cached.

Some changes end every session of the person, including the one that made the change: a password change or reset, an email change, enrolling or disabling an authenticator (enrolling returns a fresh session), deleting a passkey, disabling the identity, and administrator revocation.

Concurrent session limits

Some organizations want to stop account sharing, or simply limit how many devices hold a live session. A tenant's maxSessions (1 to 100) caps the concurrent user sessions per person. When a new session would exceed the cap, the oldest live sessions end to make room. Impersonation sessions do not count toward the cap. See Tenant policy.

Recent authentication

A session can live for days, and anyone who sits down at an unlocked computer inherits it. limits what they can do with it. Sensitive operations require the session to have been established within authentication.recentAuthenticationMs (five minutes by default, one second to fifteen minutes). They include password, email, factor, device, session, and ownership changes, impersonation, and policy changes. When the session is older, the operation fails with RECENT_AUTH_REQUIRED.

import { IamClientError } from 'better-iam/client';
import { client } from './lib/iam-client';

try {
  await client.auth.revokeOtherSessions();
} catch (error) {
  if (error instanceof IamClientError && error.code === 'RECENT_AUTH_REQUIRED') {
    const result = await client.auth.reauthenticate({ password: await askForPassword() });
    if ('mfaRequired' in result) {
      // the account has a second factor: complete it, then retry
    }
    await client.auth.revokeOtherSessions();
  } else throw error;
}

Route: POST /api/iam/auth/reauthenticate

auth.reauthenticate({ password }) re-runs the password ceremony, and the second factor when the account has one, and issues a fresh session with a new authenticatedAt; through the HTTP handler it replaces the session cookie. The console prompts for it whenever an operation answers RECENT_AUTH_REQUIRED. Two kinds of credential never qualify, whatever their age:

  • impersonation sessions (IMPERSONATION_RESTRICTED), which also cannot re-authenticate;
  • temporary credentials such as assumed-role sessions, which carry their source's authentication time.

Sign-in records and alerts

People rarely notice when someone else is guessing their password or has already signed in as them. Better IAM keeps the facts you need to tell them, the way a bank shows "last sign-in" on its home page.

Every sign-in flow keeps a small sign-in record per person: when their last session was issued and from which client, and how many attempts since then named their account with a wrong password, authenticator or emailed code, or recovery code. Each failure is recorded as an auth:signin:fail audit event with reason: 'password' | 'mfa' | 'recovery-code' and the client's IP and user agent, in a transaction of its own because the refused flow rolled back.

A new session carries the record as session.previousSignIn (absent on a first sign-in), and the record restarts. That lets you greet people with "last sign-in on Tuesday from Chrome, 3 failed attempts since", the way a login banner does:

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

const { session } = await client.auth.getSession();
const previous = session.previousSignIn; // { lastAt?, lastClient?, failedAttempts, lastFailedAt?, lastFailedClient? }

Unknown addresses, disabled accounts, and attempts the rate limiter refused are never counted, so the record cannot be used to enumerate accounts or to flood the audit log.

Two optional emails build on it:

  • New sign-in notices. With authentication.signInNotifications, or a tenant's notifyNewSignIn (which overrides the deployment), a session from a client (user agent and IP) that none of the person's live sessions or remembered devices has used queues a new-sign-in email with the session ID, method, user agent, IP, and label. Sessions without client details are never judged.
  • Failed sign-in alerts. With authentication.failedSignInAlerts set to a number (1 to 1,000; needs sendEmail), the person receives one sign-in-failures email the moment the streak reaches that number, sent only to a verified address, so they hear about a guessing attempt before their next sign-in.

The security trail

An account page is more trustworthy when people can see what happened to their account. auth.listSecurityEvents({ limit? }) gives people their own authentication trail without any administrative permission: the auth:* audit events recorded for their identity, newest first (50 by default, at most 200). It covers sign-ins, failed attempts, sign-outs, factor and password changes, and remembered devices. Each event carries its metadata (the client's ip and userAgent, the sign-in method, or a failure's reason) and names the administrator in impersonatorId when one acted through impersonation.

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

const events = await client.auth.listSecurityEvents({ limit: 20 });
// [{ id, action: 'auth:session:create', timestamp, metadata: { method, ip, userAgent } }, ...]

Route: POST /api/iam/auth/listSecurityEvents

EventRecorded when
auth:identity:createThe identity was created.
auth:session:createA session is issued (with method, ip, userAgent).
auth:session:revoke, auth:session:revoke-othersA session is ended, or every other session.
auth:session:mismatchA bound session was presented from another network (both addresses in the metadata).
auth:signin:failA wrong password, code, or recovery code for a real account.
auth:password:change, auth:password:resetThe password changed.
auth:email:verify, auth:email:change, auth:phone:verifyContact details were verified or changed.
auth:mfa:enable, auth:mfa:disable, auth:mfa:recover, auth:mfa:recovery-codesThe authenticator changed or a recovery code was used.
auth:passkey:create, auth:passkey:rename, auth:passkey:deletePasskeys changed.
auth:device:trust, auth:device:revokeA device was remembered or forgotten.

Session kinds and fields

Not every credential is a person's sign-in. Sessions come in five kinds, visible to policies as principal.sessionKind:

KindIssued by
userSign-in flows and impersonation.
api-keycredentials.create for service accounts and agents.
roleroles.assume and web-identity exchanges.
session-tokenTemporary credentials minted from a user session or API key.
delegatedAn AI agent acting for a person under a delegation; the identity is the person.

Code that branches on the kind must fail closed for kinds it does not know.

The session a sign-in returns (SafeSession) never includes tokenHash. Its main fields:

Prop

Type

Options

OptionTypeDefaultDescription
authentication.sessionLifetimeMsnumber7 daysAbsolute session lifetime, one minute to 30 days.
authentication.sessionIdleTimeoutMsnumber24 hours or the lifetimeIdle timeout, one minute up to the lifetime.
authentication.recentAuthenticationMsnumber5 minutesWindow for sensitive operations, one second to 15 minutes.
authentication.signInNotificationsbooleanfalseEmail new-sign-in notices for unfamiliar clients.
authentication.failedSignInAlertsnumber0Email sign-in-failures after this many failed attempts, 1 to 1,000; 0 is off.
http.clientInfo(request) => { ip?, userAgent?, label? }User-Agent onlyClient details recorded on sessions.
http.persistentCookiesbooleantrueWhether session cookies outlive the browser by default.
http.cookieSameSite'lax' | 'strict''lax'The session cookie's SameSite attribute.
Tenant sessionLifetimeMs, sessionIdleTimeoutMsnumberdeployment valuesShorter limits for the tenant's members.
Tenant maxSessionsnumberunlimitedConcurrent user sessions per person, 1 to 100.
Tenant notifyNewSignInbooleandeployment valueOverrides signInNotifications.
Tenant bindSessionsToIpbooleanfalseA session works only from the IP it was issued from.

Schema

CollectionKey fieldsNotes
sessionsid, tenantId, identityId, tokenHash, kind, createdAt, expiresAt, lastSeenAt, authenticatedAt, mfa, mfaAuthenticatedAt, method, client, trustedDeviceId, impersonatorId, impersonatorSessionId, previousSignInOne record per session of every kind. Only the token's hash is stored.
authSignInsid (the identity ID), identityId, lastAt, lastClient, failedAttempts, lastFailedAt, lastFailedClientThe per-person sign-in record behind previousSignIn and failed sign-in alerts.

Expired sessions are deleted by the retention jobs.

Errors

CodeWhen
UNAUTHENTICATEDThe session is missing, lapsed, revoked, or its identity is no longer active.
SESSION_NETWORK_MISMATCHA session bound to its network is presented from another address.
MFA_REQUIREDThe session did not pass MFA and the person now requires it.
EMAIL_UNVERIFIEDEmail verification is required and the address is unverified.
IP_NOT_ALLOWED, IP_BLOCKEDThe recorded client IP is outside the allowlist or blocked.
RECENT_AUTH_REQUIREDA sensitive operation from a session that is not recent.
IMPERSONATION_RESTRICTEDAn impersonation session tried a sensitive operation or to re-authenticate.
NOT_FOUNDrevokeSession with a session ID that is not the caller's.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page