Run in multiple regions
Deploy Better IAM in several regions, give each organization a home region and its own sign-in address, and send sign-in to the right region with WRONG_REGION redirects.
Once customers sit on several continents, each organization should sign in at a deployment near its data. This
guide runs the same Better IAM application in two regions, us-east-1 and eu-west-1, and routes every
organization's sign-in to its . You will build:
- one application deployed per region, each configured with
regionsand its owncurrentregion; - organization that name the region, such as
acme.signin.us-east-1.example.com; - organizations homed in a region at creation, and moved with
tenants.setRegion; - a global sign-in page that follows
WRONG_REGION(HTTP 421) to the organization's region; - server-side redirects for addresses that name the wrong region;
- optional custom hostnames, served by the organization's home region.
The reference for every option used here is Sign-in addresses and regions. This guide puts the pieces in order.
Regions route sign-in; they do not move data
The region check decides where sign-in is served and where sessions are issued. Authenticated calls on a
deployment's own baseURL are not refused by region. For strict data residency, give each region its own database.
Prerequisites
- A working single-region deployment. See the Quickstart and the Production checklist.
- A domain you control, with DNS that can route a wildcard per region (for example
*.signin.us-east-1.example.comto the United States load balancer). - Wildcard TLS certificates for each region's address space.
- A root administrator credential (
rootCredentialbelow) with recent authentication, for creating and moving organizations. - Node.js 22.12 or newer in every region.
Choose a database layout
Regions either share one database (for example a globally replicated PostgreSQL) or each keep their own. Decide first, because it changes how organizations are created and moved.
| Shared database | A database per region | |
|---|---|---|
| Where organization data lives | Everywhere the database replicates | Only in its home region |
| How a region knows another region's organization | From the organization's record | regions.locate(alias), your own small directory |
| Creating an organization in another region | tenants.create({ region }) from any region | Only on that region's deployment |
| Moving an organization | tenants.setRegion | Copy its data yourself, then setRegion there |
| Migrations and root | One migrate per release, one root | migrate and bootstrap in every region, one root per region |
With a shared database, every IAM transaction from every region takes the same database lock (see Optimize performance), so a region far from the database adds its round-trip time to each transaction it runs.
Configure each region
Deploy the same code everywhere and select the region with an environment variable. The pattern
{tenant}.signin.{region}.example.com builds each organization's address from its alias and its home region.
import { betterIam } from 'better-iam';
import { postgresAdapter } from 'better-iam/adapter-postgres';
import { sendEmail } from './delivery';
const region = process.env.IAM_REGION!; // 'us-east-1' or 'eu-west-1'
export const iam = betterIam({
database: postgresAdapter({ connectionString: process.env.DATABASE_URL! }),
// With a shared database, every region must use the same secret.
secret: process.env.BETTER_IAM_SECRET!,
baseURL: `https://signin.${region}.example.com`,
authentication: {
sendEmail,
// Optional: every pattern must sit under the passkey domain.
passkeys: { rpID: 'example.com', rpName: 'Acme Cloud' },
},
regions: {
current: region,
regions: {
'us-east-1': { label: 'United States', baseURL: 'https://signin.us-east-1.example.com' },
'eu-west-1': { label: 'Europe', baseURL: 'https://signin.eu-west-1.example.com' },
},
},
hosts: {
patterns: ['{tenant}.signin.{region}.example.com'],
signInPath: '/login', // where your sign-in page lives on each address
},
});Configuration is checked when betterIam() runs: regions.current must be one of regions.regions (1 to 64
regions), every region baseURL must be HTTPS outside localhost, a pattern may use {region} only with regions,
and with passkeys every pattern must sit under rpID. Any violation throws INVALID_CONFIG.
For a database per region, add locate, so this region can redirect aliases it does not hold instead of answering
NOT_FOUND:
import { lookupRegion } from './region-directory';
// ...inside betterIam({ ... }):
regions: {
current: region,
regions: {
'us-east-1': { baseURL: 'https://signin.us-east-1.example.com' },
'eu-west-1': { baseURL: 'https://signin.eu-west-1.example.com' },
},
// Your own global directory: alias -> region, or undefined when unknown.
locate: async (alias) => lookupRegion(alias),
},region-directory.ts is your module over a small global key-value store. Better IAM ships no directory; keep it to
an alias and a region per organization.
Route DNS and TLS to each region
Each region serves the IAM handler on every host of its address space. Nothing per organization is deployed.
- A wildcard DNS record per region:
*.signin.us-east-1.example.comto the United States load balancer,*.signin.eu-west-1.example.comto the European one, plus each region's ownsignin.<region>.example.com. - A wildcard certificate per region for the same names.
- Your application answering on all of those hosts with the handler mounted as usual. See Protocol mounts.
Behind a reverse proxy that rewrites Host, set hosts.forwardedHost: true, and only when your own proxy sets
X-Forwarded-Host: the address decides which organization a request is pinned to.
Migrate and bootstrap
better-iam migrate --config better-iam.config.mjs
better-iam doctor --config better-iam.config.mjs --strictIAM_REGION=eu-west-1 better-iam migrate --config better-iam.config.mjs
IAM_REGION=eu-west-1 better-iam bootstrap --config better-iam.config.mjs # once per region
IAM_REGION=eu-west-1 better-iam doctor --config better-iam.config.mjs --strictThe CLI loads better-iam.config.mjs, so that module must read IAM_REGION and build the same regions and
hosts options as lib/iam.ts. bootstrap reads BETTER_IAM_ROOT_EMAIL, BETTER_IAM_ROOT_NAME, and
BETTER_IAM_ROOT_PASSWORD. Root
administrators sign in at their region's own address (baseURL), which is never pinned to an organization. See
Deploy sequence.
Home organizations in a region
An organization's home region is its own region, else its nearest ancestor's. Set it when you create the
organization:
import { iam } from '../lib/iam';
export async function createOrganization(
rootCredential: { token: string },
rootTenantId: string,
input: { name: string; slug: string; ownerEmail: string; region: 'us-east-1' | 'eu-west-1' },
) {
const { tenant } = await iam.api.tenants.create(rootCredential, {
parentId: rootTenantId,
type: 'organization',
name: input.name,
ownerEmail: input.ownerEmail,
slug: input.slug, // the alias in {tenant}
region: input.region,
});
return tenant; // pending until the owner accepts the invitation
}Route: POST /tenants/create
- Without
region, the organization inherits its parent's region. An organization whose parent has no region, such as one directly under the root, is homed in the region that creates it. - With a database per region (
locateset),regionmay only name the current region; anything else fails withINVALID_INPUT. Create the organization on its region's deployment, and add its alias to your directory in the same code path. - Organizations that existed before you configured regions have no region, and every region serves them until you set one.
- Projects and child tenants follow their organization unless they have a region of their own.
To move an organization, a root administrator calls tenants.setRegion (it needs iam:tenants:update and recent
authentication, and is audited as tenant:region). null makes the tenant inherit its parent's region again.
import { iam } from '../lib/iam';
export async function moveOrganization(rootCredential: { token: string }, tenantId: string) {
return iam.api.tenants.setRegion(rootCredential, { tenantId, region: 'eu-west-1' });
}Route: POST /tenants/setRegion
No per-organization data export
With a database per region, setRegion only changes where sign-in is served. Better IAM has no command that
exports one organization's records: store-export, store-import, and store-copy copy whole databases into
empty ones, and config-export carries a tenant's access model (resource types, policies, roles, groups,
group bindings, access packages), not its accounts, credentials, or sessions. Copying an organization between regional databases is yours to build.
Build a global sign-in page
A sign-in page on any region can look an organization up by alias or by email domain. With a shared database, both
calls fail with WRONG_REGION everywhere but the home region, carrying the region and, when one can be built, a
location: the organization's sign-in URL in its home region. With a database per region, a region only knows its
own organizations: tenants.lookup answers WRONG_REGION for another region's alias when regions.locate finds
it, and domains.discover answers NOT_FOUND for an email domain this region's database does not hold.
import { createIamClient, IamClientError } from 'better-iam/client';
import type { iam } from '@/lib/iam';
const client = createIamClient<typeof iam>();
export async function goToOrganization(organization: string) {
try {
const org = await client.tenants.lookup({ slug: organization });
// Served here: signInUrl is the organization's canonical address.
location.assign(org.signInUrl ?? `/login?org=${org.slug}`);
} catch (error) {
if (error instanceof IamClientError && error.code === 'WRONG_REGION' && error.location)
location.assign(error.location); // the organization lives in another region
else throw error;
}
}
export async function goToOrganizationByEmail(email: string) {
try {
const org = await client.domains.discover({ email });
if (org.signInUrl) location.assign(org.signInUrl);
} catch (error) {
if (error instanceof IamClientError && error.code === 'WRONG_REGION' && error.location)
location.assign(error.location);
else throw error;
}
}Route: POST /tenants/lookup and POST /domains/discover (both public)
Both routes are public discovery data. Put network rate limits on them at your ingress.
Redirect addresses that name the wrong region
acme.signin.eu-west-1.example.com for an organization homed in us-east-1 is refused with WRONG_REGION on
API calls. For the pages you serve, check the address yourself and redirect:
import type { NextFunction, Request, Response } from 'express';
import { WrongRegionError } from 'better-iam/server';
import { iam } from '../lib/iam';
export async function regionRedirect(request: Request, response: Response, next: NextFunction) {
const host = request.headers.host ?? '';
try {
// Undefined for this deployment's own address and for hosts that are no organization's.
const org = await iam.hosts.resolve(host);
if (org) {
// Also checks the home region: WRONG_REGION when another region serves the organization
// or the address names the wrong region.
await iam.api.tenants.lookup({ host });
response.locals.organization = org; // { tenantId, name, slug, hostname, via }
}
next();
} catch (error) {
if (error instanceof WrongRegionError && error.location) return response.redirect(307, error.location);
next(error); // NOT_FOUND: no active organization uses this address
}
}iam.hosts.resolve(host) returns undefined for the deployment's own and unknown hosts and throws NOT_FOUND for an
address no active organization holds. It does not check the home region: it throws WRONG_REGION only when the
alias is missing from this region's database and regions.locate names another region. With a shared database it
returns the organization even when another region serves it, so the middleware also calls tenants.lookup({ host }),
which refuses with WRONG_REGION (and the location to redirect to) in both layouts. iam.hosts.region is this
deployment's region.
Send links that open in the right region
Every organization has one canonical sign-in URL: its primary custom hostname, else its address from the first
pattern, else its region's baseURL, followed by hosts.signInPath. You get it from iam.hosts.signInUrl(tenantId),
from signInUrl in tenants.lookup and domains.discover results, and from signInUrl on every message your
sendEmail and sendSms callbacks receive. Build invitation, reset, and magic links from it, so they open in the
organization's home region. See
Sign-in URLs and email links.
Add custom hostnames (optional)
With hosts.customHostnames: true, an organization can verify a hostname it controls, such as login.acme.com,
and it then works like the organization's subdomain. Give each region its own CNAME target:
// ...inside betterIam({ ... }):
hosts: {
patterns: ['{tenant}.signin.{region}.example.com'],
signInPath: '/login',
customHostnames: true,
cnameTarget: `custom.signin.${region}.example.com`,
},hostnames.add returns the CNAME of the deployment that handles the call, so claim the hostname on the
organization's home region. Then publish the TXT and CNAME records, call hostnames.verify, and optionally
hostnames.setPrimary. Gate on-demand certificates with iam.hosts.allowed(hostname). See
Custom hostnames.
Route: POST /hostnames/add, POST /hostnames/verify, POST /hostnames/setPrimary
What pinning and region checks refuse
| Situation | Result |
|---|---|
| A sign-in call, lookup, or discovery for an organization homed in another region | WRONG_REGION (421) with region and location |
| An address that names the wrong region | WRONG_REGION (421) |
A sign-in call on Acme's address naming another organization's tenantId | HOST_MISMATCH (403) |
| A session or API key of another organization used on Acme's address | HOST_MISMATCH (403) |
| An address whose alias no active organization holds (including a pending or suspended one) | NOT_FOUND (404) |
| A custom hostname the deployment itself uses | HOSTNAME_NOT_ALLOWED (400) |
| A custom hostname another organization verified first | HOSTNAME_TAKEN (409) |
An authenticated management call on the deployment's own baseURL for an organization homed elsewhere | Allowed: authenticated calls are not refused by region |
Session cookies are host-only (__Host- prefix), so a session started on an organization's address in one region
is never sent to another address.
Verify it works
Look up an organization from a region that is not its home. The answer is 421 with the redirect target:
curl -s -o - -w '\n%{http_code}\n' -X POST https://signin.eu-west-1.example.com/api/iam/tenants/lookup \
-H 'content-type: application/json' -H 'x-better-iam: 1' -d '{"slug":"acme"}'
# {"error":{"code":"WRONG_REGION","message":"…","region":"us-east-1","location":"https://acme.signin.us-east-1.example.com/login"}}
# 421From the home region, the same call answers 200 with tenantId, region, and signInUrl. Then:
- open
https://acme.signin.us-east-1.example.com/loginand sign in without passing atenantId; - open
https://acme.signin.eu-west-1.example.com/loginand confirm the 307 redirect to the United States address; - check the audit log for
tenant:regionafter a move.
Troubleshooting
Next steps
Every hosts and regions option, pinning rules, and sign-in URLs.
Custom hostnames APIClaim, verify, make primary, and release organization hostnames.
Tenants APItenants.create, tenants.lookup, and tenants.setRegion in detail.
Migrations, durability, and backups for each regional database.
Optimize performanceWhy database distance matters when regions share one database.
Production checklistEverything else to confirm before launch.
Better IAM is created by Sean Filimon
Last updated
Optimize performance
Measure where Better IAM spends time, then keep authorization checks, storage lookups, and background work fast with batching, query plans, indexes, retention, and offline token verification.
Migration guides
The general plan for moving people, organizations, roles, SSO connections, and directories to Better IAM: what carries over, what cannot, the bulk import APIs, password handling, dual-run cut-over, and rollback.