Multi-Tenant and B2B Authentication

One product, many customer organisations, each with their own identity provider and their own idea of who is an admin.

B2B SaaS changes the authentication problem in a specific way: the user is not your user. They belong to a customer organisation that runs its own directory, sets its own policy, and expects to switch you off for an employee the moment they are offboarded.

The three-way relationship

Every request involves a user, a tenant, and a role within that tenant. A user can belong to several tenants, with different roles in each. That single fact rules out putting a role on the user record.

users        (id, email)
tenants      (id, slug, domain, sso_config)
memberships  (user_id, tenant_id, role)   ← the actual permission

Tenant resolution

How does a request declare which tenant it is for?

  • Subdomainacme.abc.com. Clear, cacheable, and each tenant gets its own cookie scope.
  • Path/t/acme/…. Simple, but every URL carries it.
  • Token claim — the tenant is in the token. Cleanest for APIs, and the token must then be scoped to one tenant.

Whichever you choose, the tenant must be verified against the caller's membership, never taken from the request alone. A path parameter is user input.

Per-tenant identity providers

Enterprise customers will require SSO into their IdP. That means: resolve the email domain to a tenant, redirect to that tenant's configured provider, and map its group claims onto your roles. Home-realm discovery is the polite name for the email-to-IdP lookup.

The traps

  • Domain verification. Before letting a tenant claim @acme.com, prove they control it — a DNS TXT record. Otherwise anyone can claim a domain and capture its users' logins.
  • Users in multiple tenants. A consultant with three customers needs a tenant switcher, and switching must re-issue the credential, not filter the UI.
  • Cross-tenant leakage. The classic bug: authorization checks the user but not the tenant, so a valid user reads another organisation's data. Put tenant_id in every query, mechanically.
  • Offboarding. SCIM provisioning, or at minimum a re-check against the IdP, so a deactivated employee loses access without a support ticket.

Example

Example · javascript
// ❌ The tenant taken from the path is user input
app.get('/api/tenants/:tenantId/invoices', auth, async (req, res) => {
  res.json(await db.invoices.findByTenant(req.params.tenantId));
});
// GET /api/tenants/competitor/invoices  → 200. Any authenticated user.

// ✅ The path is a claim; membership is the fact
app.get('/api/tenants/:tenantId/invoices', auth, async (req, res) => {
  const membership = await db.memberships.findOne({
    userId: req.user.id,
    tenantId: req.params.tenantId,
  });
  if (!membership) return res.status(404).json({ error: 'not_found' });

  res.json(await db.invoices.findByTenant(membership.tenantId));
});

When to use it

  • A consultant belonging to three customer tenants switches between them, and each switch re-issues a token scoped to exactly one tenant.
  • An enterprise customer connects their Okta directory, and offboarding an employee there removes their access to the product within minutes.
  • A penetration test changes the tenant id in a URL and gets 404 rather than another organisation's invoices, because membership is checked.

More examples

Home-realm discovery and per-tenant SSO

Returning the same response shape for SSO and password tenants matters: a different answer per domain turns the login page into a customer list.

Example · javascript
// Step 1: the user types an email. We decide where to send them.
app.post('/auth/start', authLimiter, async (req, res) => {
  const email = String(req.body.email ?? '').toLowerCase().trim();
  const domain = email.split('@')[1];
  if (!domain) return res.status(400).json({ error: 'invalid_email' });

  // Only VERIFIED domains route to a tenant IdP. An unverified claim on
  // '@gmail.com' would otherwise hijack every consumer login.
  const tenant = await db.tenants.findOne({
    emailDomain: domain,
    domainVerifiedAt: { not: null },
  });

  if (tenant?.ssoEnabled) {
    return res.json({
      method: tenant.ssoProtocol,                 // 'saml' | 'oidc'
      redirectUrl: buildSsoUrl(tenant, email),
      tenantName: tenant.name,
    });
  }

  // No SSO → our own password flow. Identical response shape either way, so
  // this endpoint does not reveal which companies are customers.
  return res.json({ method: 'password' });
});

// Step 2: domain verification, before any of the above is allowed.
export async function verifyDomain(tenantId, domain) {
  const tenant = await db.tenants.findById(tenantId);
  const expected = `abc-verification=${tenant.domainVerificationToken}`;

  const records = await dns.promises.resolveTxt(domain).catch(() => []);
  const found = records.flat().some((r) => r.trim() === expected);

  if (!found) throw new Error('TXT record not found. Add it and try again.');

  await db.tenants.update(tenantId, {
    emailDomain: domain,
    domainVerifiedAt: new Date(),
  });
}

// Without the DNS check, any tenant claims '@microsoft.com' and every Microsoft
// employee who signs up is redirected to an IdP that tenant controls.

Tenant-scoped tokens and switching

Revoking the previous refresh family on switch means a token for the old tenant cannot be resurrected — without it, both scopes stay alive in parallel.

Example · javascript
// A token names exactly ONE tenant. Multi-tenant tokens invite the bug where a
// handler forgets which tenant this particular request is about.
function issueTokenFor(userId, membership) {
  return jwt.sign(
    {
      sub: String(userId),
      tenant: membership.tenantId,          // singular, always present
      role: membership.role,
      scope: SCOPES_BY_ROLE[membership.role].join(' '),
    },
    PRIVATE_KEY,
    { algorithm: 'RS256', expiresIn: '10m',
      issuer: ISSUER, audience: AUDIENCE, keyid: ACTIVE_KID },
  );
}

// Which tenants may this user enter?
app.get('/api/memberships', auth, async (req, res) => {
  const memberships = await db.memberships.findByUser(req.user.id);
  res.json({
    current: req.user.tenant,
    available: memberships.map((m) => ({
      tenantId: m.tenantId, name: m.tenant.name, role: m.role,
    })),
  });
});

// Switching re-issues the CREDENTIAL. Filtering the UI is not switching.
app.post('/api/switch-tenant', auth, async (req, res) => {
  const membership = await db.memberships.findOne({
    userId: req.user.id,
    tenantId: req.body.tenantId,
  });
  if (!membership) return res.status(404).json({ error: 'not_found' });

  // Some tenants require a fresh second factor on entry.
  if (membership.tenant.requireMfaOnSwitch && !req.user.mfa) {
    return res.status(401).json({ error: 'mfa_required' });
  }

  await revokeRefreshFamily(req.refreshFamilyId);   // the old scope is dead
  const refresh = await issueRefreshToken(req.user.id, membership.tenantId);
  res.cookie('rt', refresh, REFRESH_COOKIE_OPTIONS);

  res.json({
    accessToken: issueTokenFor(req.user.id, membership),
    expiresIn: 600,
    tenant: { id: membership.tenantId, role: membership.role },
  });
});

Making cross-tenant leakage structurally impossible

is_local=true in set_config is the detail that makes RLS safe with connection pooling — without it the setting outlives the transaction and leaks across requests.

Example · javascript
// Relying on every developer to remember `tenantId` in every query is a plan
// that fails on the fortieth query. Enforce it below the application.

// --- Option A: Postgres row-level security ---
// The database refuses to return other tenants' rows, whatever the query says.
//
//   ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
//   CREATE POLICY tenant_isolation ON invoices
//     USING (tenant_id = current_setting('app.tenant_id')::uuid);
//
// Set it once per request, inside the transaction:
export async function withTenant(tenantId, fn) {
  return db.transaction(async (tx) => {
    // set_config with is_local=true scopes it to THIS transaction, so a pooled
    // connection cannot carry one tenant's context into another's request.
    await tx.raw('SELECT set_config(?, ?, true)', ['app.tenant_id', tenantId]);
    return fn(tx);
  });
}

app.use(auth, (req, res, next) => {
  req.db = (fn) => withTenant(req.user.tenant, fn);
  next();
});

app.get('/api/invoices', async (req, res) => {
  // Even this — with no tenant filter written anywhere — is safe.
  res.json(await req.db((tx) => tx('invoices').select('*')));
});

// --- Option B: a repository layer that cannot be called without a tenant ---
class TenantScopedRepo {
  constructor(tenantId) {
    if (!tenantId) throw new Error('tenantId is required');   // fail loudly
    this.tenantId = tenantId;
  }
  find(table, where = {}) {
    return db(table).where({ ...where, tenant_id: this.tenantId });
  }
  // No method exists that omits the tenant. The unsafe query is unwritable.
}

// --- The test that proves it ---
it('never returns another tenant\'s rows', async () => {
  const repo = new TenantScopedRepo(acme.id);
  const rows = await repo.find('invoices', { id: globexInvoice.id });
  expect(rows).toHaveLength(0);          // exists, but not for this tenant
});

Discussion

  • Be the first to comment on this lesson.