BetterIAM
Authentication

HTTP and configuration

How the HTTP handler serves authentication: routes, cookies, CSRF and Origin checks, rate limits, headers, request IDs, and every option and email template.

@better-iam/server@better-iam/auth@better-iam/clientauthentication.mddeployment.mdhttp.tstypes.tsrate-limit.tstemplates.ts

Browsers do not call iam.api directly; they talk to the HTTP handler. The handler turns the API into JSON endpoints and adds what a browser-facing identity service needs: session cookies, protection against cross-site request forgery (CSRF), CORS for trusted origins, rate-limit hints, and request IDs. This page describes that behaviour, then lists every deployment option that shapes authentication.

Mount the handler

iam.handler is a Fetch-style (request: Request) => Promise<Response> function for runtimes and frameworks built on web standards. iam.nodeHandler is the same handler for Node's http module. Mount one of them at the base path (/api/iam by default).

import { createServer } from 'node:http';
import { iam } from './iam';

// Node.js
createServer(iam.nodeHandler).listen(3000);

// Fetch-style runtimes and route handlers
export const POST = (request: Request) => iam.handler(request);
export const GET = (request: Request) => iam.handler(request); // health and metrics

The framework integrations mount it for you and add typed helpers.

Routes

The HTTP API mirrors iam.api one to one, so anything your server can call, a browser client can call too, subject to the same authorization. Every operation is a POST with a JSON body:

RouteCalls
POST {basePath}/auth/{method}An api.auth method, such as auth/signIn or auth/verifyMfa.
POST {basePath}/{group}/{method}A provisioning method, such as identities/invite.
POST {basePath}/authorize, /authorizeMany, /listAccessibleThe authorization queries: one decision, a batch of up to 50, and the resources a caller may act on.
POST {basePath}/plugins/{pluginId}/{path}A plugin endpoint.
GET {basePath}/healthA database check that answers only up (200) or down (503).
GET {basePath}/metricsPrometheus metrics, for the configured bearer token only. See observability.

/health and /metrics are the only GET endpoints. Other methods fail with METHOD_NOT_ALLOWED (405), unknown routes with NOT_FOUND, and request bodies over 64 KiB with PAYLOAD_TOO_LARGE (413).

The router maps endpoints explicitly. Routes that people use before they hold a session need no credential: the sign-in ceremonies (password, passwordless, MFA challenge, and passkey), signUp, email verification, password reset, email-change confirmation, and four provisioning methods (tenants.lookup, domains.discover, and the two acceptInvitation methods). sts.assumeRoleWithWebIdentity is public too, because the external OpenID Connect token it exchanges is its credential. Every other route needs a session, API key, or role token. Deployment capabilities such as bootstrap, recoverRoot, raw storage, and session-issuance primitives are never routed.

The response envelope

Every answer has the same shape, so one piece of client code can handle all of them. A success is { "data": … }. A failure is { "error": { "code", "message" } } with the error's HTTP status, plus retryAfterMs for rate-limited requests. Branch on code, never on message; the error reference lists every code. The typed client unwraps the envelope and throws IamClientError with code, status, retryAfterMs, and requestId.

Cookies

A session token kept in JavaScript can be stolen by any script injected into your page. The handler keeps the browser's session in an HttpOnly cookie instead, which scripts cannot read, so your pages never handle the token.

The session cookie is better-iam.session, or __Host-better-iam.session over HTTPS. It is HttpOnly, Secure over HTTPS, SameSite=Lax (or Strict with http.cookieSameSite: 'strict'), and Path=/, with no parent domain. Loopback HTTP development uses the non-prefixed name.

  • These routes set it when they return a session: auth/signIn, auth/verifyMfa, auth/confirmMfa, auth/recoverMfa, auth/finishPasswordless, auth/finishPasskeyAuthentication, auth/finishPasskeyMfa, auth/reauthenticate, tenants/acceptInvitation, identities/acceptInvitation, and links/switch.
  • auth/signOut clears it, even when the sign-out itself is refused (for example because the session already lapsed or is presented from another network).
  • roles/assume and identities/impersonate return their token in the body only. Replacing the cookie would strand the person's own session in that browser.

Persistence. The cookie normally lasts as long as the session (Max-Age equal to its remaining lifetime). A request that issues a session may send X-Better-IAM-Persistent: 0 to receive a browser-session cookie instead, which disappears when the browser closes while the server session keeps its own lifetime. This is the console's unticked "keep me signed in". http.persistentCookies: false makes browser-session cookies the default, and X-Better-IAM-Persistent: 1 opts back in.

The device cookie better-iam.device (__Host- prefixed over HTTPS, HttpOnly) holds a "remember this device" token. A response carrying a deviceToken sets it, the handler injects it into later auth/signIn and auth/finishPasswordless bodies that do not carry their own, and auth/revokeTrustedDevices clears it.

CSRF and Origin checks

A browser attaches cookies to cross-site requests automatically, so a malicious page could otherwise make a signed-in person's browser call your API. The handler blocks this with three checks on every POST:

  1. The body must be JSON (Content-Type: application/json) and the request must carry X-Better-IAM: 1. Plain HTML forms and simple cross-site requests cannot set either, so they fail with CSRF_REJECTED.
  2. A request that carries cookies must carry an Origin header (CSRF_REJECTED otherwise).
  3. Any Origin must exactly match a trusted origin: baseURL's origin or an entry of trustedOrigins (exact origins only). Others fail with UNTRUSTED_ORIGIN.

For a trusted origin, responses carry CORS headers (Access-Control-Allow-Origin set to that origin, credentials allowed, and Retry-After and X-Request-Id exposed), so a browser client on another trusted origin can read error codes. OPTIONS preflights answer 204. A refused origin gets no CORS headers.

The typed client sends the JSON content type and X-Better-IAM: 1 on every call. Send them yourself when you call the API with fetch or curl.

Bearer tokens

Servers, scripts, and mobile apps usually send a token explicitly instead of relying on cookies. Authorization: Bearer <token> works for every route: session tokens, API keys, assumed-role tokens, and impersonation tokens. A bearer credential takes precedence over the cookie and never touches cookies: a bearer-authenticated request neither sets nor clears the browser's session cookie. Bearer requests without cookies need no Origin, but still need the JSON content type and X-Better-IAM: 1.

Headers and request IDs

Identity responses must never be cached by a proxy or leak through a Referer header. Every JSON response carries Cache-Control: no-store, X-Content-Type-Options: nosniff, and Referrer-Policy: no-referrer.

Request IDs tie a browser error to your logs. Send a plain X-Request-Id (letters, digits, and ._:-, at most 128 characters) and the handler echoes it on the response, success or error, and records it as requestId on the request's http observability span. The client can generate one for every call:

const client = createIamClient<typeof iam>({
  requestId: true, // or () => myTraceId()
  onUnauthenticated: () => router.push('/login'),
});

try {
  await client.identities.invite({ tenantId, email });
} catch (error) {
  if (error instanceof IamClientError) reportToSupport(error.code, error.requestId);
}

onUnauthenticated fires for exactly the codes that mean the session can no longer be used, UNAUTHENTICATED and SESSION_NETWORK_MISMATCH, so you can send people to the login page in one place. Credential failures such as a wrong password never trigger it.

Rate limits

Rate limits stop password guessing, credential stuffing, and code brute-forcing. Sign-in, recovery, and MFA flows count attempts in persisted counters, so limits hold across restarts and across processes that share the database.

iam.ts
authentication: {
  rateLimits: {
    attempts: 10, // ordinary flows such as password sign-in
    sensitiveAttempts: 5, // MFA, recovery, and delivery requests
    windowMs: 15 * 60_000,
    ipAttempts: 50, // per client IP across every flow of a tenant; 0 (the default) turns it off
  },
},

Prop

Type

  • Per account and per challenge. Counters are kept per address or identity, and second-factor attempts are also counted per sign-in challenge and per person, so a correct password does not buy a fresh budget of code guesses.
  • Per IP. With ipAttempts, IPv6 clients are counted per /64 network and IPv4-mapped addresses as IPv4, so rotating addresses does not earn fresh counters.
  • Passkey discovery (sign-in without an email) is counted per client address with ten times the ordinary allowance, because login pages start one on every visit.
  • Tenants tighten the limits with their policy's maxAttempts; they can never raise them.
  • Refusals fail with RATE_LIMITED (429). The error body carries retryAfterMs, the response adds Retry-After in seconds, and IamClientError.retryAfterMs exposes it. With retryRateLimited: true the client retries once after the server's wait when it is short (five seconds by default, { maxWaitMs } to change).
  • Unlocking. identities.unlock clears one person's counters; it never clears a network's counter.

createMemoryRateLimiter() from better-iam/auth keeps counters in process memory for single-process deployments and tests. Network-level controls still belong at your ingress: apply request and body limits there too, especially to the public lookups.

Client details

Sessions record the client they were issued to (IP, user agent, and a device label), and several features judge the IP: tenant allowlists, session binding, network blocks, per-IP rate limits, and new sign-in notices. By default the handler records only the User-Agent header, because a client IP is only trustworthy when your own proxy sets it. Supply http.clientInfo to read it:

iam.ts
http: {
  clientInfo: (request) => ({
    // Only trust a header your own proxy or load balancer sets and overwrites.
    ip: request.headers.get('x-real-ip') ?? undefined,
    userAgent: request.headers.get('user-agent') ?? undefined,
    label: request.headers.get('x-device-name') ?? undefined,
  }),
},

Values are trimmed and bounded, and nothing about the client is ever trusted for authorization. Framework integrations that pass the incoming request's headers as the credential derive the same client details, so server actions and route handlers are judged like direct HTTP calls. For sessions you create with direct iam.api.auth.* calls, wrap the call in iam.auth.withClient(info, fn).

Never trust X-Forwarded-For blindly

Anyone can send an X-Forwarded-For header. Read the client IP only from a header your own proxy sets, or an attacker can choose the address your allowlists and blocks judge.

Configuration reference

Everything on the authentication pages is configured in three places of the betterIam(options) object: a few top-level options, the authentication block, and the http block. This reference lists them together.

Top-level options

OptionPurpose
baseURLWhere the service is reached. HTTPS is required outside localhost, 127.0.0.1, and [::1]. Its origin is always trusted.
basePathWhere the handler serves the API (/api/iam by default).
trustedOriginsAdditional exact origins allowed to call the API with cookies, and to host passkey ceremonies.
secretThe deployment secret (at least 32 characters). It seals MFA secrets and deliveries and keys token digests.
previousSecretsUp to five secrets being rotated out; see secrets.

authentication

Prop

Type

http

Prop

Type

Email and SMS templates

Better IAM never sends mail itself, so you keep your own provider, sender domain, and branding. It queues a message in the with a template name and a payload, and your sendEmail or sendSms callback renders and delivers it.

TemplateChannelSent forPayload
verify-emailemailSign-up and requestEmailVerificationtoken
password-resetemailrequestPasswordReset and administrator resetstoken
email-changeemail (new address)requestEmailChangetoken
magic-linkemailstartPasswordless with kind: 'magic-link'token
codeemail or SMSstartPasswordless with kind: 'code'token (six digits)
phone-verifySMSstartPhoneVerificationtoken (six digits)
mfa-codeemailrequestMfaCodecode
new-sign-inemailA session from an unfamiliar clientsessionId, time, method, userAgent, ip, label
sign-in-failuresemailThe failed-attempt streak reached failedSignInAlertsattempts, time, ip, userAgent
owner-invitationemailtenants.createtoken, tenantId, tenantName
member-invitationemailidentities.invitetoken, tenantId, tenantName, inviterName

The governance features add certification-review and certification-reminder. Payloads are sealed in the outbox and handed to your callback decrypted, and the message's top-level tenantId names the tenant.

renderDeliveryMessage(message, { appName, links }) from better-iam/auth/templates turns any of the built-in templates into { subject, text, html }, with HTML escaping. The subpath has no native dependencies, so it also suits email workers and edge runtimes. You supply link builders for your own pages; a missing builder falls back to the raw token, and templates it does not know (such as phone-verify, plugin templates, or newer features) return undefined so your callback can render them itself.

iam.ts
import { renderDeliveryMessage } from 'better-iam/auth/templates';

const origin = 'https://app.example.com';

authentication: {
  sendEmail: async (message) => {
    const tenant = message.tenantId;
    const rendered = renderDeliveryMessage(message, {
      appName: 'Acme Cloud',
      links: {
        invitation: ({ kind, tenantId, token }) => `${origin}/join?kind=${kind}&tenant=${tenantId}&token=${token}`,
        passwordReset: ({ token }) => `${origin}/reset?tenant=${tenant}&token=${token}`,
        verifyEmail: ({ token }) => `${origin}/verify?tenant=${tenant}&token=${token}`,
        emailChange: ({ token }) => `${origin}/email?tenant=${tenant}&token=${token}`,
        magicLink: ({ token, destination }) =>
          `${origin}/magic?tenant=${tenant}&token=${token}&to=${encodeURIComponent(destination)}`,
        account: ({ tenantId }) => `${origin}/${tenantId}/account`, // "Review your account" on security notices
      },
    });
    if (!rendered) throw new Error(`Unknown template ${message.template}`); // the outbox retries later
    await mailer.send({ to: message.to, ...rendered });
  },
},

Delivery is at least once and happens outside the write transaction, after commit. Deduplicate by message.id, and never log tokens or payloads. See data and consistency.

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page