Nest Authbeta

Logging in under a tenant (ISOLATED)

How login resolves the tenant in ISOLATED mode — slug → tenantId, the login DTO, password reset, and the invite pattern.

In ISOLATED mode the same email is a distinct account per tenant, so authentication needs to know which tenant before it can find the account. This recipe covers how that resolution works end to end.

1. Resolve a tenant by slug (the property-picker)

A user knows their property/org name or slug, not a UUID. Turn a slug into a tenantId with the public endpoint:

GET /auth/tenants/lookup?slug=acme   →   200 { "id": "…uuid…", "slug": "acme", "name": "Acme Inc" }
                                          404 if not found / tenancy disabled
// From the client SDK (no auth required)
const res = await fetch(`${baseUrl}/auth/tenants/lookup?slug=${slug}`);
const tenant = res.ok ? await res.json() : null;   // { id, slug, name } | null

Use it to power a slug field or a "find your workspace" step, then carry tenant.id into the login form.

Enumeration: the endpoint resolves an exact slug only — it deliberately does not list or fuzzy-search tenants, so it can't be used to enumerate your customer directory. If you want a name autocomplete, build it against your own data with your own authorization (you own the tenant/property records).

GET /auth/client-config also returns the active tenant's mode, so the UI can decide whether to show the tenant step at all.

2. Log in with the tenant

Pass tenantId on the login (and signup) request. Without it, ISOLATED can't disambiguate a shared email.

await client.login({
  providerName: 'email',
  credentials: { email: 'alice@acme.test', password },
  tenantId: tenant.id,        // ← required in ISOLATED
});

The lookup is tenant-scoped: the account in that tenant is found (or login fails), never a same-email account in a different tenant.

switchTenant is disabled in ISOLATED — to act under a different tenant, log in to it directly. To keep several tenants signed in at once and flip between them, use the account switcher.

3. Password reset & verification carry the tenant

forgot-password, verify-forgot-password-otp, reset-password, and the verify-* requests all accept tenantId, and the flow is tenant-correct end to end:

  • forgot-password scopes the account lookup by the resolved tenantId, so the reset code goes to the right same-email account.
  • the reset token issued by verify-forgot-password-otp embeds the tenantId (and is bound to that user), so the reset lands in the correct isolated tenant even though the email link is opened later in a fresh browser.

So your reset email link should round-trip the tenant (e.g. include the slug/tenantId) so the reset form can re-supply it:

https://app.example.com/reset?tenant=acme&token=…

4. Invite a member (first-class, since 2.5.0)

Inviting a member is a single call. It creates-or-links the user in the tenant, mints a single-use, tenant-scoped set-password token, and emits a nest_auth.user_invited event carrying that token — so you send the email, exactly like the password-reset / signup events. The token is never returned in the response (that would leak a working credential into logs / the network tab).

// From your own admin-guarded controller / service:
const { user, isNewUser } = await this.inviteService.inviteUser({
  email: 'member@acme.test',
  tenantId,                 // ISOLATED: invite into the right tenant
  metadata: { name: 'Pat' }, // echoed on the event for your email template
});

Listen for the event and send the email:

@OnEvent(NestAuthEvents.USER_INVITED)
async onInvited({ payload }: UserInvitedEvent) {
  const link = `https://app.example.com/set-password?token=${payload.token}&tenant=${tenantSlug}`;
  await this.mailer.sendInvite(payload.user.email, link, payload.metadata);
}

The member opens the link and sets their password via POST /auth/reset-password { token, newPassword }, then signs in normally. The token is single-use — once the password is set it can't be replayed.

There's also a guarded HTTP endpoint, POST /auth/invite (body: { email?, phone?, tenantId?, metadata? }), protected by the users.invite permission — assign it to your admin roles, or just call InviteService.inviteUser() from your own guarded controller (more flexible, you own the authorization).

Why an event and not a token in the response? Returning a set-password/invite token is a credential leak (logs, browser network tab, anywhere the admin response is seen). Emitting it on an event keeps it on the server side, consistent with every other email the library triggers.

On this page