B2B auth patterns: org-scoped logins, team management, and SSO enforcement

B2B SaaS authentication has structural requirements that consumer auth doesn't: users belong to organizations, organizations have their own identity policies, access is scoped by membership, and enterprise customers demand SSO. Getting the data model right before you have thousands of tenants is critical — retrofitting org isolation after the fact is one of the more painful engineering migrations in the SaaS lifecycle.

The core data model

-- Users are global — they can belong to multiple orgs
CREATE TABLE users (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email        TEXT NOT NULL UNIQUE,
  display_name TEXT,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Orgs are the tenant unit
CREATE TABLE orgs (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug         TEXT NOT NULL UNIQUE,    -- used in URLs: app.example.com/o/{slug}
  display_name TEXT NOT NULL,
  plan         TEXT NOT NULL DEFAULT 'free',
  sso_enabled  BOOLEAN NOT NULL DEFAULT FALSE,
  sso_domain   TEXT,                    -- e.g. 'acme.com' — enforces SSO for this domain
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Membership: the join between users and orgs, with org-scoped role
CREATE TABLE org_members (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id       UUID NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
  user_id      UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  role         TEXT NOT NULL DEFAULT 'member',   -- 'owner', 'admin', 'member', 'viewer'
  invited_by   UUID REFERENCES users(id),
  joined_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE(org_id, user_id)
);

CREATE INDEX idx_org_members_user ON org_members(user_id);
CREATE INDEX idx_org_members_org ON org_members(org_id);

The key design decision: users are first-class entities independent of any org. A user's identity (email, credentials, MFA) is global. Their role and permissions are org-scoped. This allows a consultant to be a member of multiple client orgs, and allows SSO users to log in once and access their org without separate credentials.

Member invitation flow

Invitations work by creating a signed token that links a pending membership to an email address. When the invitee clicks the link, they authenticate (or create an account), and the pending membership is confirmed.

async function createInvitation(
  orgId: string,
  inviterUserId: string,
  email: string,
  role: string,
  db: DB
): Promise {
  // Check inviter has permission to invite at this role level
  const inviterMembership = await db.orgMembers.find({ orgId, userId: inviterUserId });
  if (!canInviteAtRole(inviterMembership.role, role)) {
    throw new AuthError('INSUFFICIENT_PERMISSIONS', 'Cannot invite at this role level');
  }

  // Check if already a member
  const existingUser = await db.users.findByEmail(email);
  if (existingUser) {
    const existing = await db.orgMembers.find({ orgId, userId: existingUser.id });
    if (existing) throw new AuthError('ALREADY_MEMBER', 'User is already a member');
  }

  const token = crypto.randomBytes(32).toString('hex');
  const expiresAt = new Date(Date.now() + 7 * 86400 * 1000);  // 7 days

  await db.invitations.create({
    orgId,
    invitedEmail: email.toLowerCase(),
    invitedByUserId: inviterUserId,
    role,
    tokenHash: crypto.createHash('sha256').update(token).digest('hex'),
    expiresAt,
  });

  const inviteUrl = `https://app.example.com/invite/${token}`;
  await sendInvitationEmail(email, inviteUrl, orgId);

  return inviteUrl;
}

async function acceptInvitation(token: string, userId: string, db: DB) {
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
  const invitation = await db.invitations.findByTokenHash(tokenHash);

  if (!invitation) throw new AuthError('INVALID_TOKEN', 'Invitation not found');
  if (invitation.expiresAt < new Date()) throw new AuthError('TOKEN_EXPIRED', 'Invitation expired');
  if (invitation.acceptedAt) throw new AuthError('ALREADY_ACCEPTED', 'Invitation already used');

  const user = await db.users.findById(userId);
  if (user.email.toLowerCase() !== invitation.invitedEmail) {
    throw new AuthError('EMAIL_MISMATCH', 'This invitation is for a different email address');
  }

  await db.transaction(async trx => {
    await trx.orgMembers.create({
      orgId: invitation.orgId,
      userId,
      role: invitation.role,
      invitedBy: invitation.invitedByUserId,
    });
    await trx.invitations.markAccepted(invitation.id);
  });
}

SSO enforcement by domain

Enterprise customers often require that all users with a corporate email domain must authenticate via their IdP. When SSO is enforced for a domain, users with that email domain cannot use password login — they're redirected to the org's SSO connection.

async function resolveLoginMethod(email: string, db: DB): Promise {
  const domain = email.split('@')[1]?.toLowerCase();
  if (!domain) throw new AuthError('INVALID_EMAIL', 'Invalid email address');

  // Check if any org enforces SSO for this domain
  const org = await db.orgs.findByDomain(domain);

  if (org?.ssoEnabled && org.ssoEnforced) {
    // This email domain is SSO-only — redirect to the org's IdP
    const ssoConfig = await db.ssoConnections.findByOrgId(org.id);
    return {
      type: 'sso',
      orgId: org.id,
      ssoConnectionId: ssoConfig.id,
      loginUrl: buildSsoLoginUrl(ssoConfig, email),
    };
  }

  if (org?.ssoEnabled && !org.ssoEnforced) {
    // SSO is available but not required — show both options
    return { type: 'choice', orgId: org.id };
  }

  return { type: 'password' };
}

Team roles vs product roles

In B2B SaaS, there are typically two layers of roles that get conflated:

  • Team/org roles: Owner, Admin, Member, Viewer — controls what users can do within the org's account: invite members, change billing, access admin settings.
  • Product roles: Editor, Analyst, ReadOnly — controls what users can do within the product: edit content, run reports, view data.

Keep these separate in your data model. A billing admin who can add seats and change payment methods doesn't necessarily need product edit access. A power user who should have full product access might not be able to see team management settings.

-- Team roles in org_members.role (owner/admin/member/viewer)
-- Product roles in a separate table
CREATE TABLE product_roles (
  id       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id   UUID NOT NULL REFERENCES orgs(id),
  user_id  UUID NOT NULL REFERENCES users(id),
  role     TEXT NOT NULL,  -- 'editor', 'analyst', 'viewer'
  UNIQUE(org_id, user_id)
);

Just-in-time provisioning

When SSO is enabled, users who authenticate via their org's IdP for the first time should be automatically provisioned — created in your system and added to the org — without requiring a separate invitation. This is just-in-time (JIT) provisioning.

async function jitProvisionUser(
  ssoAssertion: SsoAssertion,
  orgId: string,
  db: DB
): Promise {
  const email = ssoAssertion.email.toLowerCase();

  return db.transaction(async trx => {
    // Find or create the user
    let user = await trx.users.findByEmail(email);
    if (!user) {
      user = await trx.users.create({
        email,
        displayName: ssoAssertion.displayName,
        ssoProviderUserId: ssoAssertion.providerUserId,
      });
    }

    // Find or create org membership
    let membership = await trx.orgMembers.find({ orgId, userId: user.id });
    if (!membership) {
      const org = await trx.orgs.findById(orgId);
      const defaultRole = org.ssoDefaultRole ?? 'member';

      membership = await trx.orgMembers.create({
        orgId,
        userId: user.id,
        role: defaultRole,
        joinedAt: new Date(),
      });
    }

    return user;
  });
}

Configure a default role for JIT-provisioned users at the org level — typically member or viewer. Admins can then elevate specific users. This is more secure than defaulting to a high-access role and safer than blocking new SSO users until manually approved (which creates support tickets on day one of every new enterprise deployment).