Identity Data Modeling: The Decision You Cannot Cheaply Undo
Updated 2026-09-12 · 15 min read · By @guptadeepak
Key takeaways
- A users.tenant_id column is the cheapest thing to write and the most expensive thing to remove. It bakes one-org-per-user into every query, token, and cached authorization decision.
- Membership belongs in its own table with its own lifecycle. Roles, status, invitation state, and provisioning source all hang off the membership, not off the user.
- A pending membership is what makes invitations work without creating phantom user records that pollute counts, billing, and search.
- SCIM-owned memberships must be read-only in your own admin UI, or an admin's edit is silently reverted at the next sync.
- Own the organization, membership, and role tables in your own database. Treat the identity vendor as a federated credential source, not as your system of record.
- Identity in one tenant is not identity in another. Decide early whether a person is one global account with many memberships or one account per organization.
The most expensive four bytes in B2B SaaS
The failure is not that the column is wrong on day one. It is usually right on day one. The failure is that a schema is a set of claims about the world, and this one claims something the world eventually contradicts. A contractor works for two of your customers. An agency manages five. A customer acquires another customer and wants the accounts merged. Somebody signs up with a personal address and later joins a corporate workspace.
Each of those is a support ticket in a one-org model and a row in a many-org model.
Four objects, and what each one owns
A workable B2B identity model has four entities. The discipline is keeping their responsibilities separate, because almost every design mistake in this area is an attribute stored on the wrong one.
The person. A globally unique human with credentials. Email, authentication factors, name, locale, and nothing organization-specific. If an attribute would have a different value in a different organization, it does not belong here.
The organization. The customer. Billing relationship, domain claims, identity-provider configuration, feature flags, data residency, and policy settings. Named organization, tenant, workspace, or account depending on your product's vocabulary, and worth naming once and consistently because it leaks into your API forever.
The membership. The join between a person and an organization, and the object almost nobody creates early enough. It owns the role, the status, the invitation metadata, the provisioning source, the join date, and any per-organization profile such as a job title or a display name. It is a first-class entity with its own lifecycle, not a link table.
The role or permission grant. What a membership is allowed to do. In simple products this is an enum on the membership. In products with resource-level sharing it becomes its own table, and eventually a relationship model along the lines described in RBAC vs ABAC vs ReBAC.
A minimal shape:
users (
id, email, email_verified_at, created_at
)
organizations (
id, name, slug, created_at
)
memberships (
id,
user_id NULL, -- null while an invitation is pending
organization_id,
invited_email NULL, -- set while pending, cleared on accept
role,
status, -- pending | active | suspended | removed
source, -- manual | invitation | scim | jit | domain_claim
external_id NULL, -- the IdP's identifier when source = scim
invited_by, invited_at, accepted_at, removed_at,
UNIQUE (user_id, organization_id)
)The source column is the one teams add last and need most. Without it, you cannot tell a manually created membership from one the customer's identity provider owns, and that distinction determines whether your own admin UI is allowed to edit the row.
Status is what makes the model work
Membership status carries more weight than any other field, because it is what lets one table serve invitation, suspension, and removal without any of those becoming a special case.
Pending. The invitation. The row exists, user_id is null, invited_email holds the address, and the intended role is already decided. No user record is created. This is the detail that keeps invitations from polluting everything downstream: phantom users inflate seat counts, appear in admin lists, break "user must have a verified email" invariants, and get counted in billing. A pending membership is invisible to all of it while still being a real, queryable object with an expiry.
On acceptance, the invitee authenticates, a user record is created or matched, user_id is populated, invited_email is cleared, and status becomes active. One transaction.
Active. The normal state.
Suspended. Access revoked, relationship retained. This matters more than it looks: an organization that suspends a departing employee usually wants their history, comments, and ownership of records to survive. Deleting the membership orphans all of it.
Removed. Soft-deleted with a timestamp, for audit. Hard deletion is a data-subject-request path, not an offboarding path.
Where identity actually lives
The most important boundary decision in a B2B identity architecture is which system is authoritative for which object. Getting it wrong does not break anything immediately. It shows up later as a reporting query you cannot write, a permission check that fails during a vendor outage, and a migration with no export path. The split that holds up is narrower than most vendor documentation suggests.
The vendor is authoritative for authentication. Who this person is, what factors they used, when they last logged in, and whether the assertion from their employer's identity provider is valid. That is genuinely hard, genuinely commodity, and correctly outsourced.
You are authoritative for organization, membership, and role. These are business objects. They join against your data, they appear in your reports, they drive your billing, and they change on your product's schedule rather than your vendor's.
Storing roles and organization structure inside a vendor's user metadata feels efficient, and it produces three problems. You cannot join it against anything. You cannot query it in bulk without paginating an API. And it becomes the part of a migration that has no export path, which is the same asymmetry described in password hash migration.
Do
Key local records to the vendor's subject identifier
Keep your own primary key, and store the vendor's subject as a join column you can repoint.
Changing vendors then changes one column rather than every foreign key in the schema.
Own organization, membership, role, and invitation state
These are business objects that join against your data and drive your billing and reporting.
Anything stored only in vendor metadata cannot be joined, queried in bulk, or reliably exported.
Let the vendor own the login experience
Domain routing to the right identity provider is exactly the kind of work worth outsourcing.
Authentication is commodity; authorization is your product.
Write authorization checks against your own tables
Permissions should resolve from data you control, at a latency you control.
A vendor outage should degrade new logins, not existing users' access decisions.
Don't
Store roles in vendor user metadata
Reading roles back over an API at request time couples every permission check to a third party.
It is also the part of a migration with no export path.
Use the vendor's user ID as your primary key
It propagates into every table and every API response you have ever published.
Vendor identifiers are not stable across platforms or across account merges.
Assume the vendor's organization model matches yours
Most default to one membership per user, which is the assumption you are trying to avoid.
Check the multi-membership behaviour during evaluation, not after.
Put authorization logic in an identity provider rule
Code that lives in a vendor console is invisible to review and untestable locally.
It is also the first thing to break silently after a platform update.
The SCIM constraint on membership
Once an enterprise customer connects their identity provider, the ownership of membership changes for that organization, and your data model has to represent the change explicitly.
The provider becomes authoritative for who is in the organization. It creates memberships when IT adds someone to a group, deactivates them on offboarding, and reconciles on every sync. If a group mapping drives roles, it is authoritative for those too. Any edit made in your own admin UI is overwritten the next time the sync runs, which is the silent-failure version of the worst possible behavior: the admin sees their change succeed, and it disappears hours later with no notification.
Three requirements fall out of this.
- Mark the source. Every membership records whether it came from SCIM, an invitation, just-in-time provisioning on SSO login, or a manual admin action.
- Make provider-owned rows read-only in your UI. Not hidden. Visible, uneditable, and labelled with the reason and where to go instead.
- Reconcile and alarm. Compare your membership set against the provider's periodically, and alert on divergence and on the absence of sync events. SCIM provisioning covers the protocol; the operational point is that a stalled sync produces no error at all.
Just-in-time provisioning needs the same treatment with an added subtlety. A JIT membership is created by the act of logging in, so it cannot be deprovisioned by the absence of logging in. A product with JIT and no SCIM has an onboarding mechanism and no offboarding mechanism, which is exactly the gap an enterprise security questionnaire is written to find.
The question to settle on day one
One decision has no cheap reversal, and it is worth making deliberately: is a person one global account with many memberships, or one account per organization?
Global account, many memberships. One credential, an organization switcher, and per-organization state on the membership. This is what most modern B2B products do, and it is what users expect after using Slack, Notion, and Figma. It requires you to answer what happens when the same human is invited under two different email addresses, and to accept that a person's authentication factors are shared across every organization they belong to.
Isolated account per organization. The same human authenticating separately in each tenant, with no shared credential. Correct when regulatory separation, per-tenant data residency, or hard isolation guarantees demand it, and painful for everyone else. It multiplies password resets, multiplies MFA enrollments, and makes the switcher impossible.
The hybrid that causes trouble is a global account with tenant-scoped credentials, where authentication is shared but factor enrollment is per-organization. It reads as flexible on a whiteboard and produces a recovery flow nobody can reason about.
Whichever you choose, write the decision down with its reasoning, because it will be questioned by someone who was not there, and the answer determines the shape of organizations and tenants, the multi-tenant architecture, and every authorization check you will ever write.
A migration path if the column is already there
Most teams read this with a tenant_id column already in production. It is recoverable, and the order matters.
- Add the membership table and backfill it from the existing column, one row per user. Nothing changes behaviorally.
- Add a dual-read path. New code reads membership. Old code keeps reading the column. Both are populated by writes.
- Move the token. Stop putting a single organization in the session or access token as a permanent fact. Carry an active organization that the user can change, and re-derive permissions from membership on each request or from a cache keyed by user and organization together.
- Convert call sites by module, with the authorization layer first, then reporting, then anything that renders a user's organization.
- Make the column non-authoritative, then stop writing it, then drop it.
- Only then ship multi-org membership in the UI. The schema change and the feature are two separate releases, and combining them is how a backfill error becomes a cross-tenant data exposure.
Step three is the one that takes the longest, because a tenant_id claim in a long-lived token tends to have been treated as immutable, and a surprising amount of code will have come to rely on that.
Related vendors
Auth0
Auth0 remains the safest mid-market default for B2C plus B2B Enterprise SSO when developer velocity matters more than long-run TCO. Auth0 for AI Agents (GA November 2025) and Auth for MCP (GA May 2026) make it the first major CIAM with a packaged agent-identity surface. Below 50k MAU it is still hard to beat. Above 500k MAU, cost and Actions-driven lock-in make FusionAuth, Cognito, or Stytch (Twilio) plus a passkey orchestrator the more honest shortlist.
Clerk
Clerk is the default for native Next.js and Node.js apps under 100k MAU. Drop-in UI is the win. It is not an enterprise CIAM: federation long tail, Java/.NET, FedRAMP, and ISO 27001 are missing or thin. Do not put Clerk on an RFP that needs the rest of the enterprise stack. For that job use Auth0, WorkOS, or SSOJet. For passwordless-native, use MojoAuth or Stytch.
Frontegg
Frontegg is the strongest B2B SaaS CIAM in 2026 by Admin Portal and self-service end-customer experience, the buyer is a SaaS engineering team that needs to ship enterprise-grade IT admin features without building them, and Frontegg delivers more of that out of the box than Auth0 or WorkOS. The trade-off is narrower B2C feature coverage and a smaller ecosystem than Auth0; for B2B-first SaaS the Admin Portal alone often justifies the choice.
PropelAuth
PropelAuth is a B2B-first developer-CIAM with a hosted self-service Org admin portal at the level of Frontegg's, at materially lower price for startup-and-mid-market scale. HIPAA-eligibility is uncommon at this price tier. For B2B SaaS startups whose customers need role hierarchies and Org-admin UX, PropelAuth shortlists with Frontegg, Kinde, and Clerk.
WorkOS
WorkOS is the strongest B2B-first CIAM in 2026 by deliberate scope choice: every product surface assumes the buyer is selling to enterprise IT, not to consumers. AuthKit's 1M MAU free tier makes it a credible Auth0 alternative for B2B SaaS that does not need adaptive risk or B2C consumer flows. In 2026 the company is also documenting MCP step-up patterns for agents; that is still a tutorial surface, not a packaged agent-identity product like Auth0 for AI Agents. For pure B2B SSO, SCIM, and audit logs, WorkOS is hard to beat at any price point.
Where to next
FAQ
- Should a user belong to one organization or many?
- Many, unless you can prove otherwise and are willing to bet the schema on it. Consultants, contractors, agencies, acquisitions, and a customer who signs up twice with the same work address all produce a person who legitimately belongs to two organizations. Modeling one-to-many costs a join table on day one. Retrofitting it later touches every query, every token claim, every cached permission, and every piece of UI that assumed a single current organization.
- What is wrong with a tenant_id column on the user table?
- It is a correct model of a wrong assumption. The column asserts that a user has exactly one organization, and that assertion propagates outward: queries filter on it, tokens carry it, caches key on it, and application code stops distinguishing between the person and their position in one organization. Removing it later is not a schema migration, it is a rewrite of every authorization decision in the product.
- Where should roles be stored, in the identity vendor or in your database?
- In your database, on the membership. A role is a business concept specific to your product, and storing it in a vendor's user metadata makes it a foreign object you cannot join against, cannot query in bulk, and cannot easily take with you. Let the vendor assert who the person is. Decide for yourself what that person may do in a given organization.
- How do invitations work without creating phantom users?
- Make the membership the invitation. Create a membership row with status pending, holding the invited email address, the target organization, the intended role, the inviter, and an expiry. No user record exists until the invitee accepts and authenticates. Pending memberships therefore never appear in seat counts, admin user lists, or search, and an expired invitation is a single row to delete.
- Can an admin edit a role that came from SCIM?
- They can, and the change will be reverted at the next sync, which is worse than refusing the edit. When an organization's memberships are provisioned from its identity provider, the provider is the source of truth for membership and often for group-derived roles. Mark those memberships with their provisioning source and make them read-only in your UI, with an explanation pointing the admin at their own IdP.
- Should organizations be hierarchical?
- Only if your customers actually are. Hierarchy is genuinely required for holding companies, franchises, school districts, and large enterprises with autonomous divisions. It also multiplies the cost of every authorization question, because permission inheritance has to be resolved across a tree. Model a flat organization with an optional parent reference if you are unsure, and do not build inheritance semantics until a customer needs them.
Sources
- SCIM 2.0 core schema, RFC 7643
- Google Zanzibar paper (2019), relationship-based authorization model
- OWASP Authorization Cheat Sheet
- Production B2B SaaS identity implementations, 2023-2026