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.
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 metricsThe 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:
| Route | Calls |
|---|---|
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, /listAccessible | The 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}/health | A database check that answers only up (200) or down (503). |
GET {basePath}/metrics | Prometheus 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, andlinks/switch. auth/signOutclears it, even when the sign-out itself is refused (for example because the session already lapsed or is presented from another network).roles/assumeandidentities/impersonatereturn 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:
- The body must be JSON (
Content-Type: application/json) and the request must carryX-Better-IAM: 1. Plain HTML forms and simple cross-site requests cannot set either, so they fail withCSRF_REJECTED. - A request that carries cookies must carry an
Originheader (CSRF_REJECTEDotherwise). - Any
Originmust exactly match a trusted origin:baseURL's origin or an entry oftrustedOrigins(exact origins only). Others fail withUNTRUSTED_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.
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 carriesretryAfterMs, the response addsRetry-Afterin seconds, andIamClientError.retryAfterMsexposes it. WithretryRateLimited: truethe client retries once after the server's wait when it is short (five seconds by default,{ maxWaitMs }to change). - Unlocking.
identities.unlockclears 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:
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
| Option | Purpose |
|---|---|
baseURL | Where the service is reached. HTTPS is required outside localhost, 127.0.0.1, and [::1]. Its origin is always trusted. |
basePath | Where the handler serves the API (/api/iam by default). |
trustedOrigins | Additional exact origins allowed to call the API with cookies, and to host passkey ceremonies. |
secret | The deployment secret (at least 32 characters). It seals MFA secrets and deliveries and keys token digests. |
previousSecrets | Up 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.
| Template | Channel | Sent for | Payload |
|---|---|---|---|
verify-email | Sign-up and requestEmailVerification | token | |
password-reset | requestPasswordReset and administrator resets | token | |
email-change | email (new address) | requestEmailChange | token |
magic-link | startPasswordless with kind: 'magic-link' | token | |
code | email or SMS | startPasswordless with kind: 'code' | token (six digits) |
phone-verify | SMS | startPhoneVerification | token (six digits) |
mfa-code | requestMfaCode | code | |
new-sign-in | A session from an unfamiliar client | sessionId, time, method, userAgent, ip, label | |
sign-in-failures | The failed-attempt streak reached failedSignInAlerts | attempts, time, ip, userAgent | |
owner-invitation | tenants.create | token, tenantId, tenantName | |
member-invitation | identities.invite | token, 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.
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.
Better IAM is created by Sean Filimon
Last updated
Impersonation
Audited "view as" sessions that let support staff see exactly what a member sees, restricted to what both people may do and visible everywhere.
Authorization
How Better IAM decides whether a principal may perform an action on a resource, and where roles, policies, boundaries, and relationships fit in.