Skip to content

Switch Tenant Flow

This page documents POST /api/v1/auth/switch-tenant, used both for the initial tenant selection after login (when Login returns a Global token) and for switching tenants during an already-authenticated session.

This page was rewritten for v5

Earlier versions of this page described a "pre-auth token" with a 5-minute TTL and available_tenants embedded as a JWT claim. That design was replaced in v5 — this page now matches the actual SwitchTenantCommandHandler contract (Global token model, 2-minute TTL, availableTenants returned only in the response body). See the v4 → v5 migration guide items 15–16.

When This Flow is Triggered

ScenarioDescription
No tenant auto-resolved at loginMultiple tenants, none default, no matching preferredTenantIdLogin returns a Global token
Mid-session switchUser already holds a Tenant (access) token and wants to switch to another tenant they belong to

Bi-Typed Endpoint

switch-tenant accepts two token types, by design ([AllowGlobalAndTenantToken], allowlist { global, access }):

  1. global — the token issued right after login when tenant selection is required. Single-use: it is blacklisted immediately after a successful switch.
  2. access (+ tenant_id claim) — an already-authenticated user switching tenant mid-session. Not single-use.

refresh, mfa_pending, and a missing token_type are all rejected with 401. In both cases SwitchTenantCommandHandler re-validates tenant access server-side — the token type is never itself the authorization decision.

Sequence Diagram — Initial Tenant Selection (Global Token)

100% 💡 Use Ctrl + Scroll para zoom | Arraste para navegar

Step-by-Step Explanation

1. Global Token Claims

json
{
  "sub": "user-id",
  "token_type": "global",
  "scope": "tenant-selector-only",
  "purpose": "tenant-selection",
  "exp": 1751462520
}

TTL is a hardcoded 2 minutes (JwtTokenService, not configurable via JwtSettings). The token carries no roles/permissions and cannot be used on any endpoint other than switch-tenant and complete-first-login.

availableTenants lives in the response body

The tenant list is not embedded in the JWT. It's returned as AuthenticationResult.AvailableTenants on the login/refresh/switch-tenant response — read it from there, never from a decoded token.

2. Switch Tenant Request

json
POST /api/v1/auth/switch-tenant
Authorization: Bearer {globalToken or accessToken}

{
  "tenantId": "550e8400-e29b-41d4-a716-446655440000"
}

SwitchTenantCommand accepts only tenantId. There is currently no setAsDefault field on this endpoint — a client cannot ask the server to remember the selection as part of this call. If your product needs "remember my organization" UX, treat it as a client-side preference (or a known gap to raise with the Auth team) rather than assuming the old setAsDefault: true contract still works.

3. Validation

  1. Token type is global (with valid scope/purpose claims) or access (with a tenant_id claim).
  2. Target tenant is resolvable for the user — direct UserTenant assignment, or the user has GroupAdmin on the parent group of a child tenant (hierarchy inheritance rule).
  3. Tenant and the UserTenant association are still active.

4. Token Generation

A full Tenant (access) token is generated with roles/permissions for the selected tenant. For child tenants, the token also carries group_id/group_name claims. If a global token was used, it is blacklisted immediately (single-use enforcement); an access token used for a mid-session switch is not blacklisted.

Switching Tenants During an Active Session

Already-authenticated users can switch tenants without a global token:

100% 💡 Use Ctrl + Scroll para zoom | Arraste para navegar

Error Scenarios

ErrorHTTP StatusCause
Session expired401Global token expired (2 min TTL)
Token type rejected401Token was refresh, mfa_pending, or missing token_type
Access denied403Tenant not accessible (no direct assignment, no GroupAdmin inheritance)
Tenant inactive403Tenant or UserTenant association deactivated

Security Considerations

Global Token

  • Hardcoded 2-minute TTL
  • Single-use — blacklisted after a successful switch
  • No roles/permissions; cannot access tenant-scoped resources
  • Valid on switch-tenant and complete-first-login only

Cross-Tenant Access

When User.AllowCrossTenantAccess = true, the user can access resources across tenants; the JWT carries additional claims for cross-tenant validation, and audit logs track cross-tenant access.

Code Example

Client-Side Implementation

typescript
interface AuthenticationResult {
  token: string;
  refreshToken?: string;
  isGlobal: boolean;
  requiresTenantSelection: boolean;
  tokenType: 'Global' | 'Tenant' | 'MfaPending';
  availableTenants?: TenantInfo[];
  currentTenant?: TenantInfo;
}

async function handleLogin(email: string, password: string): Promise<void> {
  const response = await authApi.login({ email, password });

  if (response.tokenType === 'MfaPending') {
    authStore.setPendingMfaToken(response.token);
    router.push('/mfa-challenge');
    return;
  }

  if (response.requiresTenantSelection) {
    // Store the (short-lived) global token and show the tenant selector
    authStore.setGlobalToken(response.token);
    tenantStore.setAvailable(response.availableTenants!);
    router.push('/select-tenant');
    return;
  }

  // Tenant already resolved (default/single-tenant/preferredTenantId)
  authStore.setTokens(response.token, response.refreshToken!);
  router.push('/dashboard');
}

async function selectTenant(tenantId: string): Promise<void> {
  const globalToken = authStore.getGlobalToken();

  const response = await authApi.switchTenant({ tenantId }, globalToken);

  authStore.clearGlobalToken();
  authStore.setTokens(response.token, response.refreshToken!);
  authStore.setCurrentTenant(response.currentTenant);
  router.push('/dashboard');
}

// Switch tenant during an active session
async function switchTenant(newTenantId: string): Promise<void> {
  const response = await authApi.switchTenant({ tenantId: newTenantId });

  authStore.setTokens(response.token, response.refreshToken!);
  authStore.setCurrentTenant(response.currentTenant);

  window.location.reload();
}

Tenant Selector Component (Vue)

vue
<template>
  <div class="tenant-selector">
    <h2>Select Your Organization</h2>
    <div class="tenant-list">
      <div
        v-for="tenant in availableTenants"
        :key="tenant.id"
        class="tenant-card"
        @click="selectTenant(tenant.id)"
      >
        <h3>{{ tenant.name }}</h3>
      </div>
    </div>
  </div>
</template>

No "remember my choice" checkbox

Since switch-tenant has no setAsDefault field, this component intentionally drops the old checkbox — there is currently no API-level way to persist the selection as the user's default tenant from this call.

Released under the MIT License.