Founder & Lead Engineer, RAITHub
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.
How should an invitation link be issued and accepted?
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.
| Case | Wrong behaviour | Right behaviour |
|---|---|---|
| Invited email already has an account | Create a second account | Add that existing user to the workspace on accept |
| Invite accepted twice (double click) | Two memberships, or an error page | Idempotent: the second accept is a no-op |
| Last owner tries to leave or be removed | Workspace with no owner, locked out | Block it until another owner is promoted |
| Seat limit reached mid-accept | One member over the paid limit | Refuse inside the transaction, prompt to upgrade |
| User removed from workspace | Stale sessions still have access | Every 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?
| Option | Examples | Choose this when | Watch out for |
|---|---|---|---|
| Auth platform with organisations | Hosted auth with built-in orgs, members and invites | You want sign-in, orgs and invites out of the box and accept their data model | Your membership and role data lives in their system; deep custom roles can fight the product |
| Auth library, orgs in your DB | A session or token library plus your own membership tables | You want standard auth but your own workspace and role model | You write the invitation flow and the edge cases yourself |
| Build it in fully | The model above | Workspaces, roles and seats are core to your product and pricing | You own every edge case in the table above; test them all |
| Hire a team to build it | RAITHub or another studio | Workspaces, invitations, roles and seat limits must agree from the first release | Insist 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.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.