BetterIAM

Build a multi-tenant B2B SaaS

Build a Next.js B2B app where each customer is an organization with its own owner, invited members, product roles, sign-in policy, organization switcher, and authorized routes.

@better-iam/next@better-iam/server@better-iam/client@better-iam/auth@better-iam/adapter-sqlite@better-iam/clitenants.tsidentities.tstrust.tsbindings.tsdecisions.tsoptions.tsflows.tstemplates.tsbase.tsindex.tsiam.tslayout.tsx

This guide builds the identity layer of a B2B product on the Next.js App Router. Every customer is an organization: a with its own people, roles, and sign-in rules, served under its own path (/acme/...). The platform team creates organizations, each organization's owner invites their colleagues, and every route checks access inside the organization the data belongs to. You will build:

  • a configured Better IAM instance with a project resource type and a resolver that reports which organization owns each project;
  • organization creation by a platform administrator, with an owner invitation email;
  • an invitation page for owners and members, and default product roles (Admin, Member) created for each new organization;
  • member invitations with a role, and delegation so admins other than the owner can invite;
  • an organization settings page that sets the organization's own sign-in policy;
  • an organization layout at /[org], an organization switcher, and project routes that authorize every request.

Prerequisites

  • Node.js 22.12 or newer, and a Next.js 15 App Router project with "type": "module" in package.json.
  • A root administrator created with better-iam bootstrap and enrolled in MFA. The Quickstart walks through both. Keep the root tenant's ID (tenant.id in the bootstrap output).
  • The Next.js integration concepts: createIamNext, page, route, and action. This guide uses them without re-explaining each option.

Install the packages

npm i @better-iam/next @better-iam/client @better-iam/server @better-iam/auth @better-iam/adapter-sqlite
npm i -D @better-iam/cli

@better-iam/cli provides the better-iam command for migrations and scheduled jobs. To use a different database, swap the adapter (see Installation).

Configure the instance

The configuration lives in a module the CLI can load, better-iam.config.mjs. Its default export holds the options; your application and better-iam migrate both read it.

better-iam.config.mjs
import { sqliteAdapter } from '@better-iam/adapter-sqlite';
import { renderDeliveryMessage } from '@better-iam/auth/templates';
import { IamError } from '@better-iam/server';
import { findProject } from './lib/projects-store.mjs';

const origin = process.env.BETTER_IAM_BASE_URL ?? 'http://localhost:3000';

export default {
  database: sqliteAdapter({ filename: process.env.BETTER_IAM_DATABASE ?? '.data/iam.db' }),
  secret: process.env.BETTER_IAM_SECRET,
  baseURL: origin,
  authentication: {
    // Development only: prints each message. In production, hand `rendered` to your mail provider.
    sendEmail: async (message) => {
      const rendered = renderDeliveryMessage(message, {
        appName: 'Acme Cloud',
        links: {
          invitation: ({ kind, tenantId, token }) =>
            `${origin}/invite?kind=${kind}&tenantId=${tenantId}&token=${token}`,
          passwordReset: ({ tenantId, token }) => `${origin}/reset?tenantId=${tenantId}&token=${token}`,
        },
      });
      console.log(message.to, rendered?.subject, rendered?.text);
    },
  },
  permissions: {
    resourceTypes: {
      project: { actions: ['projects:read', 'projects:write', 'projects:delete'] },
    },
  },
  // Authorization asks which organization owns a resource. Never trust a tenant ID from the request.
  resolveResource: async ({ tenantId, type, id }) => {
    // Product actions checked on the organization itself (such as creating a project) name iam/{tenantId}.
    if (type === 'iam' && id === tenantId) return { tenantId, type, id };
    const project = type === 'project' ? await findProject(id) : undefined;
    if (!project) throw new IamError('NOT_FOUND', 'Resource not found', 404);
    return { type, id, tenantId: project.tenantId };
  },
  // Every new organization starts with these sign-in rules.
  tenantDefaults: {
    authPolicy: { requireMfaForOwners: true },
  },
  // Lets one person link their accounts in several organizations, for the organization switcher.
  onboarding: { mode: 'linked' },
};
  • authentication.sendEmail is required: tenants.create fails with DELIVERY_REQUIRED without it, because the owner invitation is an email. renderDeliveryMessage builds the subject, text, and HTML of the built-in templates; links.invitation points owner and member invitations at the /invite page you build in a later step.
  • permissions.resourceTypes declares what your product protects, so roles that name projects:* actions are validated when they are saved.
  • resolveResource returns the owning tenantId of each project. When it differs from the tenant a check runs in, the check fails with RESOURCE_MISMATCH (403), which is what stops one organization from reading another's project by guessing its ID. Checks of iam:* actions never reach the resolver, but checks of your own actions do, including a check on the organization itself (iam/{tenantId}, the default resource of <iamNext.Can>, allowed(), and the guards' authorize), so the resolver answers for that resource too.
  • tenantDefaults.authPolicy is stamped on every tenant tenants.create creates.
  • onboarding: { mode: 'linked' } enables account linking (links.create); without it linking fails with LINKING_DISABLED.

The resolver reads your own data. A minimal in-memory store keeps this guide self-contained; replace it with your database:

lib/projects-store.mjs
/** @type {Map<string, { id: string; tenantId: string; name: string }>} */
const projects = new Map();

export async function findProject(id) {
  return projects.get(id);
}

export async function listProjects(tenantId) {
  return [...projects.values()].filter((project) => project.tenantId === tenantId);
}

export async function createProject(tenantId, name) {
  const project = { id: crypto.randomUUID(), tenantId, name };
  projects.set(project.id, project);
  return project;
}

Create the instance and the Next.js helpers:

lib/iam.ts
import { betterIam } from '@better-iam/server';
import config from '../better-iam.config.mjs';

export const iam = betterIam(config);
lib/iam-next.ts
import { createIamNext } from '@better-iam/next';
import { iam } from './iam';

export const iamNext = createIamNext(iam, {
  loginPath: '/login',
  stepUpPath: '/reauth',
  interrupts: 'forbidden',
  dispatchAfterResponse: true, // deliver queued email after each response
});

Keep the server and its native modules out of the bundle, and enable auth interrupts for interrupts: 'forbidden':

next.config.mjs
/** @type {import('next').NextConfig} */
export default {
  serverExternalPackages: [
    '@better-iam/server',
    '@better-iam/auth',
    '@better-iam/adapter-sqlite',
    'argon2',
    'better-sqlite3',
  ],
  experimental: { authInterrupts: true },
};

Migrate, mount, and add the auth pages

Create the schema with the CLI. Run it in a shell where BETTER_IAM_SECRET and BETTER_IAM_BASE_URL are set.

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

Mount the IAM HTTP API, which the browser client, the sign-in forms, and protocol mounts use:

app/api/iam/[...path]/route.ts
import { iamNext } from '@/lib/iam-next';

export const runtime = 'nodejs';
export const { GET, POST, OPTIONS } = iamNext.handlers();

Export the drop-in authentication server actions. discover: true finds the organization from a verified email domain when the form names none.

app/auth-actions.ts
'use server';
import { iamNext } from '@/lib/iam-next';

const auth = iamNext.authActions({ afterSignIn: '/', discover: true });
export const signIn = auth.signIn;
export const reauthenticate = auth.reauthenticate;
export const acceptInvitation = auth.acceptInvitation;
export async function signOut() {
  await auth.signOut();
}

The login page receives ?org= and ?next= from the organization layout's redirect:

app/login/page.tsx
import { SignInForm } from '@better-iam/next/client';
import { signIn } from '../auth-actions';

export default async function Login(props: {
  searchParams: Promise<{ next?: string; org?: string; tenantId?: string }>;
}) {
  const { next, org, tenantId } = await props.searchParams;
  return (
    <main>
      <h1>Sign in</h1>
      <SignInForm
        action={signIn}
        keepSignedIn
        {...(org ? { org } : {})}
        {...(tenantId ? { tenantId } : {})}
        {...(next ? { next } : {})}
      />
    </main>
  );
}

The invitation page serves both kinds of invitation email. Owners continue to /welcome, members to the home page:

app/invite/page.tsx
import { InvitationForm } from '@better-iam/next/client';
import { acceptInvitation } from '../auth-actions';

export default async function Invite(props: {
  searchParams: Promise<{ kind?: string; tenantId?: string; token?: string }>;
}) {
  const { kind, tenantId, token } = await props.searchParams;
  if (!tenantId || !token || (kind !== 'owner' && kind !== 'member'))
    return <p>This invitation link is incomplete.</p>;
  return (
    <main>
      <h1>{kind === 'owner' ? 'Set up your organization' : 'Join your team'}</h1>
      <InvitationForm
        action={acceptInvitation}
        kind={kind}
        tenantId={tenantId}
        token={token}
        next={kind === 'owner' ? '/welcome' : '/'}
      />
    </main>
  );
}

Redirect signed-out visitors at the edge, keeping the invitation page public. The middleware's default protect leaves the login path and /api/iam public, so identity providers and the browser client reach it.

middleware.ts
import { NextResponse } from 'next/server';
import { createIamMiddleware } from '@better-iam/next/edge';

export const middleware = createIamMiddleware({
  loginPath: '/login',
  publicPaths: ['/', '/invite'],
  next: (init) => NextResponse.next(init),
});
export const config = { matcher: ['/((?!_next|favicon.ico).*)'] };

Create organizations

Organizations are created by the platform. tenants.create creates a pending child tenant under the root, claims its alias (slug), and queues the owner-invitation email. It requires iam:tenants:create on the parent, a valid , and a credential authenticated within the last five minutes (authentication.recentAuthenticationMs, default 5 minutes). Better IAM has no unauthenticated "create an organization" endpoint, so call it from a platform administration page that only root administrators reach.

app/platform/page.tsx
import { iamNext } from '@/lib/iam-next';
import { createOrganization } from './actions';

export default iamNext.page(
  async () => (
    <main>
      <h1>New organization</h1>
      <form action={createOrganization}>
        <input name="name" placeholder="Acme" required />
        <input name="slug" placeholder="acme" required />
        <input name="ownerEmail" type="email" placeholder="owner@acme.com" required />
        <button type="submit">Create and invite the owner</button>
      </form>
    </main>
  ),
  { authorize: { action: 'iam:tenants:create' }, stepUp: { maxAgeMs: 5 * 60_000 } },
);
app/platform/actions.ts
'use server';
import { redirect } from 'next/navigation';
import { iamNext } from '@/lib/iam-next';

export async function createOrganization(form: FormData) {
  // Re-checks the session and its age when the form is posted, not only when the page rendered.
  const session = await iamNext.requireSession({ stepUp: { maxAgeMs: 5 * 60_000 } });
  const { tenant } = await iamNext.client().tenants.create({
    parentId: session.session.tenantId, // the root tenant the administrator signed in to
    type: 'organization',
    name: String(form.get('name')),
    slug: String(form.get('slug')),
    ownerEmail: String(form.get('ownerEmail')),
  });
  redirect(`/platform?created=${tenant.id}`);
}

The page guard checks iam:tenants:create in the administrator's own tenant (the root) and sends anyone whose sign-in is older than five minutes to the step-up page first. tenants.create enforces the permission again, so a failed check rejects the action with an IamClientError carrying the server's code. Root administrators sign in at /login?tenantId=<root tenant ID> and must complete MFA. For the step-up page itself, see Step-up.

The new tenant stays pending, and its alias does not resolve, until the owner accepts. Owner invitations last 24 hours (onboarding.invitationLifetimeMs); tenants.resendInvitation sends a fresh link.

Route: POST /api/iam/tenants/create

Create the default roles

Roles belong to a tenant, so each organization needs its own copy of your product roles. Create them once, as the owner, right after the owner accepts. A role whose name already exists is skipped, so the function is safe to run again.

lib/org-defaults.ts
import { iam } from './iam';

/** `{ headers }` or `{ token }`: who is calling. */
type CredentialInput = Parameters<typeof iam.api.roles.list>[0];

const defaults = [
  {
    name: 'Admin',
    description: 'Manages projects and members',
    permissions: [
      'projects:read',
      'projects:write',
      'projects:delete',
      'iam:identities:create',
      'iam:identities:read',
      'iam:bindings:create',
      'iam:bindings:read',
      'iam:bindings:delete',
      'iam:roles:read',
    ],
  },
  { name: 'Member', description: 'Reads and edits projects', permissions: ['projects:read', 'projects:write'] },
];

export async function ensureDefaultRoles(credential: CredentialInput, tenantId: string) {
  const existing = new Set((await iam.api.roles.list(credential, { tenantId })).map((role) => role.name));
  for (const role of defaults)
    if (!existing.has(role.name)) await iam.api.roles.create(credential, { tenantId, ...role });
}

The owner lands on /welcome after accepting. The page offers one button that creates the roles and continues to the organization:

app/welcome/page.tsx
import { iamNext } from '@/lib/iam-next';
import { finishSetup } from './actions';

export default iamNext.page(async (_props, { session }) => (
  <main>
    <h1>Welcome, {session.identity.name}</h1>
    <form action={finishSetup}>
      <button type="submit">Create the Admin and Member roles</button>
    </form>
  </main>
));
app/welcome/actions.ts
'use server';
import { redirect } from 'next/navigation';
import { iamNext } from '@/lib/iam-next';
import { ensureDefaultRoles } from '@/lib/org-defaults';

export async function finishSetup() {
  const session = await iamNext.requireSession();
  const tenantId = session.session.tenantId;
  await ensureDefaultRoles(await iamNext.credential(), tenantId);
  const tenant = await iamNext.client().tenants.get({ tenantId });
  redirect(`/${tenant.slug}`);
}

A role records the grant authority of whoever creates it, and only that authority (or a root administrator) can edit it later. Running this as the owner makes the owner's authority the roles' issuer, so the organization manages its own roles instead of depending on the platform. roles.create needs iam:roles:create and a grant authority; the owner's protected Owner role allows every action in the tenant. See Roles and bindings.

Route: POST /api/iam/roles/create

Serve each organization under its path

requireTenantSession({ slug }) resolves the alias in the URL and requires a session in that organization. Unknown or inactive aliases render a 404, and a visitor signed in to a different organization is sent to /login?org={slug}&next=....

app/[org]/layout.tsx
import type { ReactNode } from 'react';
import Link from 'next/link';
import { iamNext } from '@/lib/iam-next';
import { signOut } from '../auth-actions';
import { OrgSwitcher } from './org-switcher';

export default async function OrgLayout(props: { params: Promise<{ org: string }>; children: ReactNode }) {
  const { org } = await props.params;
  const { tenant, session } = await iamNext.requireTenantSession({ slug: org });
  return (
    <>
      <header>
        <Link href={`/${org}`}>
          <strong>{tenant.name}</strong>
        </Link>
        <OrgSwitcher current={org} />
        <form action={signOut}>
          <span>{session.identity.name} </span>
          <button type="submit">Sign out</button>
        </form>
      </header>
      {props.children}
    </>
  );
}

tenant is { tenantId, name, type, slug }. Wrap each page below the layout with iamNext.page too, because Next.js keeps layouts across client-side navigations instead of re-rendering them.

Add the organization switcher

Identities are per organization: a consultant who works with Acme and Globex has two separate accounts, each with its own password, factors, and roles. Linking the accounts lets the switcher list both. links.list returns the current identity's linked accounts with tenantName and tenantSlug.

app/[org]/org-switcher.tsx
import Link from 'next/link';
import { iamNext } from '@/lib/iam-next';

export async function OrgSwitcher({ current }: { current: string }) {
  const linked = await iamNext.client().links.list();
  const others = linked.filter((account) => account.tenantSlug && account.tenantSlug !== current);
  if (!others.length) return null;
  return (
    <nav aria-label="Switch organization">
      {others.map((account) => (
        <Link key={account.id} href={`/${account.tenantSlug}`}>
          {account.tenantName}
        </Link>
      ))}
    </nav>
  );
}

Following a link to another organization's path makes its layout's requireTenantSession send the person to /login?org={slug}, where they sign in to that account. The browser holds one session at a time, so switching is a sign-in, never a shared session: each organization's MFA and sign-in policy still apply. links.switch does the same from the API, and also requires a recently authenticated credential for the target account.

Accounts are linked with links.create({ targetCredential }), which needs recent authentication on both accounts. An owner can link their existing account while accepting an owner invitation by passing linkCredential to tenants.acceptInvitation. Root administrators can never link. See Account linking.

Route: POST /api/iam/links/list

Invite members with a role

identities.invite records the email with the roles to apply, stores only a hash of the token, and queues a member-invitation email that links to your /invite page. The inviter needs iam:identities:create, plus iam:bindings:create on each role, so an invitation never grants more than the inviter could grant directly.

app/[org]/members/page.tsx
import { iamNext } from '@/lib/iam-next';
import { InviteForm } from './invite-form';

export default iamNext.page(
  async (_props: { params: Promise<{ org: string }> }, { session }) => {
    const tenantId = session.session.tenantId;
    const [members, roles] = await Promise.all([
      iamNext.client().identities.list({ tenantId, kind: 'user' }),
      iamNext.client().roles.list({ tenantId }),
    ]);
    const assignable = roles.filter((role) => !role.protected);
    return (
      <main>
        <h1>Members</h1>
        <ul>
          {members.map((member) => (
            <li key={member.id}>
              {member.name} ({member.email})
            </li>
          ))}
        </ul>
        <InviteForm roles={assignable.map((role) => ({ id: role.id, name: role.name }))} />
      </main>
    );
  },
  { authorize: { action: 'iam:identities:read' } },
);
app/[org]/members/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { iamNext } from '@/lib/iam-next';

export const inviteMember = iamNext.action(
  async (session, _previous: unknown, form: FormData) => {
    const invitation = await iamNext.client().identities.invite({
      tenantId: session.session.tenantId,
      email: String(form.get('email')),
      roleIds: [String(form.get('roleId'))],
    });
    revalidatePath('/[org]/members', 'page');
    return { email: invitation.email, expiresAt: invitation.expiresAt };
  },
  { authorize: { action: 'iam:identities:create' } },
);
app/[org]/members/invite-form.tsx
'use client';
import { useActionState } from 'react';
import { inviteMember } from './actions';

export function InviteForm({ roles }: { roles: { id: string; name: string }[] }) {
  const [state, formAction, pending] = useActionState(inviteMember, null);
  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <select name="roleId">
        {roles.map((role) => (
          <option key={role.id} value={role.id}>
            {role.name}
          </option>
        ))}
      </select>
      <button disabled={pending}>Invite</button>
      {state?.ok === true && <p role="status">Invitation sent to {state.data.email}.</p>}
      {state?.ok === false && <p role="alert">{state.error.message}</p>}
    </form>
  );
}

Acceptance creates the identity with a verified email and binds the roles under the inviter's grant authority, which is checked again at that moment. Inviting an email that already has an identity in the organization fails with IDENTITY_EXISTS. identities.listInvitations, identities.resendInvitation, and identities.revokeInvitation manage pending invitations.

Route: POST /api/iam/identities/invite

Let admins other than the owner invite

Creating bindings, which an invitation with roles does, needs a grant authority as well as the permission. The owner holds one; anyone else has none until it is delegated, and their invitations with roles fail with GRANT_AUTHORITY_REQUIRED. The owner delegates an authority whose ceiling caps everything the admin can ever grant:

app/[org]/members/delegate.ts
'use server';
import { iamNext } from '@/lib/iam-next';

// Called by the owner when they make someone an Admin.
export const delegateInviting = iamNext.action(
  async (session, identityId: string) =>
    iamNext.client().authorities.create({
      tenantId: session.session.tenantId,
      identityId,
      ceiling: {
        version: 1,
        statements: [{ effect: 'allow', actions: ['projects:*'], resources: ['*'] }],
      },
    }),
  { stepUp: { maxAgeMs: 5 * 60_000 } },
);

With this ceiling, an admin can invite people as Member, but whatever a role says, a binding they issue grants no more than projects:*. authorities.create requires iam:authorities:create and recent authentication. Revoking the authority (authorities.revoke) disables every grant issued under it. See Grant authorities.

Route: POST /api/iam/authorities/create

Let each organization set its sign-in policy

tenants.setAuthPolicy replaces an organization's sign-in rules. A policy can only tighten what the deployment allows. It needs iam:tenants:update and recent authentication, and tenants.get returns the current policy as authPolicy.

app/[org]/settings/security/page.tsx
import { iamNext } from '@/lib/iam-next';
import { savePolicy } from './actions';

export default iamNext.page(
  async (_props: { params: Promise<{ org: string }> }, { session }) => {
    const tenant = await iamNext.client().tenants.get({ tenantId: session.session.tenantId });
    const policy = tenant.authPolicy ?? {};
    return (
      <form action={savePolicy}>
        <label>
          <input type="checkbox" name="requireMfa" defaultChecked={policy.requireMfa === true} /> Require MFA for
          everyone
        </label>
        <label>
          Idle timeout (minutes)
          <input name="idleMinutes" type="number" min={1} defaultValue={(policy.sessionIdleTimeoutMs ?? 0) / 60_000 || ''} />
        </label>
        <button type="submit">Save</button>
      </form>
    );
  },
  { authorize: { action: 'iam:tenants:update' }, stepUp: { maxAgeMs: 5 * 60_000 } },
);
app/[org]/settings/security/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { iamNext } from '@/lib/iam-next';

export async function savePolicy(form: FormData) {
  const session = await iamNext.requireSession({ stepUp: { maxAgeMs: 5 * 60_000 } });
  const tenantId = session.session.tenantId;
  const current = (await iamNext.client().tenants.get({ tenantId })).authPolicy ?? {};
  const idleMinutes = Number(form.get('idleMinutes'));
  // setAuthPolicy replaces the whole policy and enforces iam:tenants:update itself.
  await iamNext.client().tenants.setAuthPolicy({
    tenantId,
    authPolicy: {
      ...current,
      requireMfa: form.get('requireMfa') === 'on',
      ...(idleMinutes > 0 ? { sessionIdleTimeoutMs: idleMinutes * 60_000 } : {}),
    },
  });
  revalidatePath('/[org]/settings/security', 'page');
}

Existing sessions follow the new policy at their next use: turning on requireMfa stops sessions that did not complete MFA. Every field (allowed methods, IP ranges, password rules, session limits) is listed in Tenant authentication policy.

Route: POST /api/iam/tenants/setAuthPolicy

Authorize every project route

Pages, route handlers, and server actions each check the action on the specific project. The tenant defaults to the session's organization, and resolveResource confirms the project belongs to it.

app/api/projects/[id]/route.ts
import { iamNext } from '@/lib/iam-next';
import { findProject } from '@/lib/projects-store.mjs';

export const runtime = 'nodejs';

export const GET = iamNext.route<{ id: string }>(
  async (_request, { params }) => findProject(params.id),
  {
    authorize: {
      action: 'projects:read',
      resource: ({ params }) => ({ type: 'project', id: params.id }),
    },
  },
);
app/[org]/projects/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { iamNext } from '@/lib/iam-next';
import { createProject } from '@/lib/projects-store.mjs';

export async function newProject(form: FormData) {
  const session = await iamNext.requireSession();
  const tenantId = session.session.tenantId;
  // Creating a project is checked on the organization itself (iam/{tenantId}).
  await iamNext.require({ tenantId, action: 'projects:write', resource: { type: 'iam', id: tenantId } });
  await createProject(tenantId, String(form.get('name')));
  revalidatePath('/[org]/projects', 'page');
}
app/[org]/projects/page.tsx
import { iamNext } from '@/lib/iam-next';
import { listProjects } from '@/lib/projects-store.mjs';
import { newProject } from './actions';

export default iamNext.page(async (_props: { params: Promise<{ org: string }> }, { session }) => {
  const projects = await listProjects(session.session.tenantId);
  return (
    <main>
      <h1>Projects</h1>
      <iamNext.Can action="projects:write" fallback={<p>You can view projects but not create them.</p>}>
        <form action={newProject}>
          <input name="name" required />
          <button type="submit">New project</button>
        </form>
      </iamNext.Can>
      <ul>
        {projects.map((project) => (
          <li key={project.id}>{project.name}</li>
        ))}
      </ul>
    </main>
  );
});
  • route() answers a signed-out request with 401 and a denial with 403, as a JSON error envelope ({ error: { code, message } }).
  • A project from another organization fails with RESOURCE_MISMATCH (403), even for that organization's owner.
  • <iamNext.Can> is an advisory check for rendering. Enforce the mutation itself with action() or route().

For lists, filter by session.session.tenantId in your own query as above, or turn policies into a query filter with data filtering.

Verify it works

  1. Start the app, sign in at /login?tenantId=<root tenant ID> as the root administrator, and complete MFA.
  2. On /platform, create Acme with the slug acme and your own address as the owner. The terminal prints the owner invitation with its /invite?kind=owner... link.
  3. Open the link, choose a name and password, and enroll an authenticator when asked (requireMfaForOwners from tenantDefaults applies to the new owner). Press the button on /welcome. You land on /acme.
  4. On /acme/projects, create a project. On /acme/members, invite a second address as Member and accept that invitation in a private window.
  5. As the member, open /api/projects/<id> for the acme project: it answers 200 with the project. Create a second organization the same way, add a project there, and open its ID as the acme member: it answers 403 with RESOURCE_MISMATCH.
  6. As the owner, turn on Require MFA for everyone on /acme/settings/security. The member's session stops working at its next use, and their next sign-in asks them to enroll an authenticator.

The audit log records each step: tenant:activate when the owner accepts, identity:invitation:accept for the member, and tenant:auth-policy for the policy change.

Troubleshooting

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page