Back to BlogArchitecture & Engineering

Team Workspaces and Invitations for a B2B SaaS

Rupak Amin

Founder & Lead Engineer, RAITHub

7 min read

RAITHub ships and tests production software. See QA as a Service or talk to us.

Team workspaces let several users share one account, each with their own role; invitations are how they join. Build a membership table joining users to workspaces with a role, issue signed single-use invite tokens that expire, check seat limits inside the transaction that adds the member, and handle the cases teams miss: an invited email that already has an account, or the last owner leaving.

If you would rather have workspaces and invitations built for you, see how RAITHub would build this below.

What is a team workspace, and why model membership as its own table?

A workspace (or organisation, or team) is the account that owns the data; a user is a person; membership is the join between them, carrying the role. Modelling membership as its own table is what lets one user belong to several workspaces and hold a different role in each, which B2B buyers expect. A "company_id on the user row" design cannot do that, and you discover it the day an agency wants to manage two clients.

This is the same tenant boundary your whole product is scoped by. If you have not settled single- versus multi-tenant yet, that decision comes first; it is covered in multi-tenant vs single-tenant SaaS.

What should the membership and invitation model look like?

Three tables: workspaces, memberships, and pending invitations. The invitation becomes a membership on accept and is then consumed.

CREATE TABLE memberships (
  workspace_id uuid NOT NULL,
  user_id      uuid NOT NULL,
  role         text NOT NULL,          -- 'owner' | 'admin' | 'member'
  created_at   timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (workspace_id, user_id)
);

CREATE TABLE invitations (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id uuid NOT NULL,
  email        citext NOT NULL,        -- case-insensitive
  role         text NOT NULL,
  token_hash   text NOT NULL,          -- store the hash, never the raw token
  expires_at   timestamptz NOT NULL,
  accepted_at  timestamptz,            -- NULL until used; single-use
  UNIQUE (workspace_id, email) WHERE accepted_at IS NULL
);

Two points carry the security. Store only the token_hash, so a database leak does not hand out working invite links. The partial UNIQUE stops two live invites to the same email for one workspace, which would otherwise create duplicate-accept confusion. Who can do what inside a workspace is enforced by roles; the permission model is in SaaS authorization and RBAC design, and the admin screen for it in building a roles and permissions UI.

Generate a long random token, email the raw value, store only its hash, and set an expiry. On accept, look up by hash, check it is unexpired and unaccepted, create the membership and mark the invite accepted, all in one transaction.

import { randomBytes, createHash, timingSafeEqual } from 'node:crypto'

export function createInviteToken() {
  const raw = randomBytes(32).toString('base64url')       // goes in the email link
  const hash = createHash('sha256').update(raw).digest('hex') // goes in the database
  return { raw, hash }
}

// On accept: one transaction, so an invite can never be used twice.
export async function acceptInvite(db: Tx, rawToken: string, userId: string) {
  const hash = createHash('sha256').update(rawToken).digest('hex')
  const inv = await db.one(
    "SELECT * FROM invitations WHERE token_hash = $1 AND accepted_at IS NULL AND expires_at > now() FOR UPDATE",
    [hash],
  )
  await assertSeatAvailable(db, inv.workspace_id)          // inside the same transaction
  await db.query('INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING', [inv.workspace_id, userId, inv.role])
  await db.query('UPDATE invitations SET accepted_at = now() WHERE id = $1', [inv.id])
}

The FOR UPDATE lock plus the single transaction means two clicks of the same link cannot both create a membership. The seat check belongs inside this transaction, not before it, so a race cannot push you one over the limit.

Which edge cases do teams get wrong?

These are the ones that generate support tickets and security bugs. Decide each before launch.

CaseWrong behaviourRight behaviour
Invited email already has an accountCreate a second accountAdd that existing user to the workspace on accept
Invite accepted twice (double click)Two memberships, or an error pageIdempotent: the second accept is a no-op
Last owner tries to leave or be removedWorkspace with no owner, locked outBlock it until another owner is promoted
Seat limit reached mid-acceptOne member over the paid limitRefuse inside the transaction, prompt to upgrade
User removed from workspaceStale sessions still have accessEvery request re-checks membership, not just the session

Seat limits are an entitlement, counted against the plan. Enforcing them atomically, and what happens on a downgrade below current usage, is covered in SaaS entitlements, plans and limits.

Should you build this or use an auth platform?

OptionExamplesChoose this whenWatch out for
Auth platform with organisationsHosted auth with built-in orgs, members and invitesYou want sign-in, orgs and invites out of the box and accept their data modelYour membership and role data lives in their system; deep custom roles can fight the product
Auth library, orgs in your DBA session or token library plus your own membership tablesYou want standard auth but your own workspace and role modelYou write the invitation flow and the edge cases yourself
Build it in fullyThe model aboveWorkspaces, roles and seats are core to your product and pricingYou own every edge case in the table above; test them all
Hire a team to build itRAITHub or another studioWorkspaces, invitations, roles and seat limits must agree from the first releaseInsist the handover covers the last-owner and seat-race cases, with tests

Do-it-yourself estimate: 4–7 days for the membership and invitation model, signed single-use tokens, the accept flow, seat checks and the edge cases, if auth already exists. The main risk is the invitation flow's security: raw tokens stored in plain text, invites that never expire, or accepts that are not idempotent. Auth library choices are compared in Better Auth vs NextAuth vs Clerk.

How RAITHub would build this

As part of a new SaaS build, or added to an existing product, scoped in writing after the free audit.

  • Membership model: workspaces, a membership join with roles, and one user able to belong to several workspaces.
  • Invitation flow: signed single-use tokens stored as hashes, with expiry, resend and revoke, and an idempotent accept.
  • Seat limits: checked inside the accept transaction against the plan, with an upgrade prompt when full.
  • Edge cases handled and tested: existing-account invites, the last owner, double accepts and access revocation on removal.

Timeline: inside a new product, this is part of the 4–6 week fixed-scope SaaS build; added to an existing backend, it fits the 6–12 week backend range, with the exact scope in the quote.

You receive: automated tests and CI for the invitation and seat races, handover docs, and full IP assigned to you under NDA.

Next step: a free 15-minute technical audit, then a written fixed quote. See the SaaS development service, or book the audit.

Frequently asked questions

Should one user be able to belong to several workspaces?

For most B2B products, yes. Agencies, consultants and partners expect it. Modelling membership as its own table, with a role per workspace, supports it; storing a single company_id on the user row does not.

How do I make invitation links secure?

Generate a long random token, send the raw value in the email, and store only its SHA-256 hash. Set an expiry, make the invite single-use, and look it up by hash on accept. A database leak then exposes no working links.

How do I stop an invite being accepted twice?

Do the lookup, seat check, membership insert and "accepted" update in one transaction with a row lock, and use ON CONFLICT DO NOTHING on the membership. The second accept of the same link then becomes a harmless no-op.

What happens when the invited email already has an account?

Add that existing user to the workspace on accept, rather than creating a second account. Match on a case-insensitive email, and if the person is signed in under a different account, prompt them to switch before accepting.

How do I enforce seat limits without going over?

Check the seat count inside the same transaction that adds the member, with the workspace row locked, so two simultaneous accepts cannot both slip under the cap. Treat the limit as an entitlement tied to the plan.

Team workspacesSaaS invitationsMulti-tenancyMembership modelSeat limitsPostgreSQL

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.