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).