Organizations, Teams, and Invitations
- saas
- organizations
- teams
- invitations
- nestjs
- postgresql
- multitenancy
- backend
In Subscription Billing and Plans we wired up the part of the product that collects money. This article covers the part that gets people into the product in the first place: how a company brings its teammates on board, and how you model the groups they work in once they're there.
This is part of the Full Stack SaaS Masterclass, a build-it-for-real series that started with choosing the tech stack and has been layering on the pieces a real SaaS needs, module by module. We already covered the organization and membership schema back in the authentication module. This article builds on that foundation and adds two things most products need soon after: teams as an optional grouping inside an organization, and an invitation flow that lets an existing member bring in someone new without you, the founder, manually creating accounts.
Invitations sound like a small feature. They're the first thing a paying customer's admin does after signing up, and a clunky or insecure version of this flow is the kind of thing that shows up in a support ticket or, worse, a security report.
Teams as a layer, not a replacement
Once an organization has more than a handful of people, someone will ask for a way to group them: a Sales team, an Engineering team, a team scoped to one client account. The instinct is to reach for a second permissions system, one where each team has its own roles and its own access rules layered on top of the organization's.
Resist that instinct at first. The organization is still the tenant boundary and still the place where meaningful security decisions get made: who can see billing, who can invite others, who can delete data. A team, for most products, is a much lighter concept: a label for filtering dashboards, assigning work, or scoping notifications. Treating it that way keeps the permission model in one place instead of two, which matters because two overlapping permission systems is where authorization bugs breed.
create table teams (
id uuid primary key default gen_random_uuid(),
organization_id uuid not null references organizations(id) on delete cascade,
name text not null,
created_at timestamptz not null default now(),
unique (organization_id, name)
);
create table team_memberships (
id uuid primary key default gen_random_uuid(),
team_id uuid not null references teams(id) on delete cascade,
user_id uuid not null references users(id) on delete cascade,
created_at timestamptz not null default now(),
unique (team_id, user_id)
);
create index idx_team_memberships_user on team_memberships (user_id);
Notice there's no role column on team_memberships in this version. That's deliberate. A user's real permissions still come from their organization-level role on the memberships table we built earlier. Team membership answers "which group is this person in," not "what are they allowed to do." If your product genuinely needs a team lead who can add or remove people from just that team, add a role column then, once you can name the exact permission it unlocks. Adding it speculatively means you're maintaining authorization logic for a feature nobody asked for yet.
Designing the invitation model
An invitation needs to survive across a gap that a plain membership row can't: the person being invited might not have an account yet. You're sending a link to an email address, not to a user ID, and the system needs to remember what that email is entitled to once they show up.
create table invitations (
id uuid primary key default gen_random_uuid(),
organization_id uuid not null references organizations(id) on delete cascade,
team_id uuid references teams(id) on delete set null,
email text not null,
role text not null check (role in ('admin', 'member')),
invited_by uuid not null references users(id),
token_hash text not null unique,
status text not null default 'pending' check (status in ('pending', 'accepted', 'revoked', 'expired')),
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
create index idx_invitations_org on invitations (organization_id);
create unique index idx_invitations_pending_email
on invitations (organization_id, lower(email))
where status = 'pending';
That last index is doing real work. It stops the same email address from having two pending invitations to the same organization at once, which would otherwise let someone accept a stale one after their role was already changed by a newer invite.
The token_hash column, not a raw token, is the important detail here. Store the hash the same way you'd store a password, and email the raw token to the invitee as a URL parameter. If your database is ever exposed through a bug or a backup leak, an attacker with the hashes still can't accept invitations, the same reasoning we'd apply to password reset tokens.
// invitations/invitations.service.ts
import { Injectable } from '@nestjs/common';
import { randomBytes, createHash } from 'crypto';
import { InvitationsRepository } from './invitations.repository';
import { MailService } from '../mail/mail.service';
const INVITE_TTL_HOURS = 168; // 7 days
@Injectable()
export class InvitationsService {
constructor(
private readonly invitations: InvitationsRepository,
private readonly mail: MailService,
) {}
async invite(params: {
organizationId: string;
invitedBy: string;
email: string;
role: 'admin' | 'member';
teamId?: string;
}) {
const rawToken = randomBytes(32).toString('hex');
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const invitation = await this.invitations.createPending({
organizationId: params.organizationId,
teamId: params.teamId ?? null,
email: params.email.toLowerCase(),
role: params.role,
invitedBy: params.invitedBy,
tokenHash,
expiresAt: new Date(Date.now() + INVITE_TTL_HOURS * 60 * 60 * 1000),
});
await this.mail.sendInvitation({
to: params.email,
acceptUrl: `${process.env.APP_URL}/invitations/accept?token=${rawToken}`,
});
return invitation;
}
}
Accepting an invitation needs a transaction, because you're doing two things that must succeed or fail together: creating the membership and marking the invitation used. Without that, a retried request or a double-click on the accept button can create duplicate memberships or, worse, mark the invite consumed before the membership actually landed.
// invitations/invitations.service.ts
async accept(rawToken: string, userId: string, userEmail: string) {
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
return this.invitations.runInTransaction(async (tx) => {
const invitation = await tx.findPendingByTokenHash(tokenHash);
if (!invitation || invitation.expiresAt < new Date()) {
throw new BadRequestException('This invitation is invalid or has expired');
}
if (invitation.email !== userEmail.toLowerCase()) {
throw new ForbiddenException('This invitation was sent to a different email address');
}
await tx.createMembership({
organizationId: invitation.organizationId,
userId,
role: invitation.role,
});
if (invitation.teamId) {
await tx.createTeamMembership({ teamId: invitation.teamId, userId });
}
await tx.markAccepted(invitation.id);
return invitation;
});
}
The email comparison in that method matters more than it looks. Invitations are addressed to an email, not a user ID, so the accept endpoint has to check that the logged-in user's email actually matches the one invited. Skip that check, and anyone with an account who gets hold of a leaked or forwarded invite link could join an organization they were never invited to.
Tradeoffs in how invites are delivered
There are two broad patterns for getting someone into an organization, and most products end up wanting both eventually.
Individual, single-use invitations (what we built above) are the safer default. Each invite is addressed to one email, expires, and can be revoked. The cost is friction: an admin adding twenty people has to type twenty email addresses, and every invite is a database row and an email send to manage.
Shareable org invite links (a single reusable link, often with a role attached, that anyone can use to join) trade that friction for a different risk. They're convenient for onboarding a whole team at once, but the link itself becomes a bearer credential. Anyone who has it can join your organization, whether they got it forwarded in Slack, screenshotted, or posted somewhere it shouldn't be. If you build these, give them their own expiry and a way to regenerate the link, which silently invalidates the old one, and keep that separate from the per-email invitation flow.
Start with individual invitations. Add the shareable link only when a customer explicitly asks for bulk onboarding, and treat the two as separate features with separate revocation paths rather than bolting the link version onto the same invitations table without thinking through what "revoke" means when a thousand people might have used the same token.
Production pitfalls worth naming
Email enumeration through invite errors. If your "invite a teammate" endpoint returns a different error for "this email already has an account" versus "this email doesn't exist yet," you've built a way to check whether any email address is a customer of yours. Keep the response generic; do the account-linking logic quietly on the backend regardless of which case it is.
Orphaned invitations after a role or team is deleted. The on delete set null on team_id above is intentional: deleting a team shouldn't fail because there's a pending invite pointing at it, and it shouldn't delete the invitation either. Decide this for every foreign key on the table rather than letting the database's default behavior decide for you.
Not revoking invites when circumstances change. If the admin who sent an invitation is later removed from the organization, or the invited email's role changes before they accept, a stale invite sitting in someone's inbox can still be valid. A scheduled job that expires anything past expires_at, plus an explicit revoke action tied to admin and membership changes, closes that gap.
Rate limiting the invite endpoint. Sending invitation email is an outbound message to an address you don't control yet. Without a rate limit per organization or per inviting user, it becomes a way to spam arbitrary email addresses through your product's mail sender reputation, which is a problem for your deliverability long after the abuse stops.
Key takeaways
- Keep teams as a lightweight grouping inside an organization; let the organization membership's role remain the actual source of permissions until a specific feature demands otherwise.
- Model invitations against an email address, not a user ID, since the invitee may not have an account yet.
- Store a hash of the invite token, not the raw value, and verify the invited email matches the accepting user before creating a membership.
- Wrap invitation acceptance in a transaction so a duplicate request can't create two memberships or leave the invitation in an inconsistent state.
- Prefer single-use, per-email invitations by default; add shareable org-wide invite links only when there's a concrete need, and give them their own revocation path.
- Rate-limit invitation sends and keep error responses generic to avoid turning the flow into an email-enumeration or spam vector.
Frequently asked questions
Should teams have their own permission system separate from the organization's?
Not by default. Keep the organization's role (owner, admin, member) as the single source of truth for what a user can do, and treat teams as a grouping label for filtering and assignment. Add team-scoped permissions only once a real feature needs them, so you're not maintaining two overlapping authorization systems.
How long should an invitation link stay valid?
A week is a reasonable default for most B2B products; long enough that someone checking email sporadically doesn't miss it, short enough that a forwarded or leaked link doesn't stay useful indefinitely. Whatever window you pick, expire it server-side and check that expiry on every accept request, not just when the email is sent.
What happens if I invite someone who already has an account with a different email?
Nothing automatically. The invitation is bound to the email address it was sent to, so the person needs to sign in with an account tied to that same email to accept it, or you need an explicit "add an existing email to this invite" flow. Don't try to guess identity across email addresses; that's a fast way to attach the wrong account to an organization.
Can the same email have two pending invitations to the same organization?
Block it at the database level with a partial unique index on pending invitations for that organization and email, rather than relying on application code to catch it. That closes a race condition where two admins invite the same person moments apart with different roles.
Is it safe to let an invitation carry an admin role directly?
Yes, as long as the endpoint that creates the invitation checks that the inviting user is themselves allowed to grant that role. An organization member shouldn't be able to invite someone as an admin if members aren't permitted to create admins; that check belongs on invitation creation, not just on membership creation later.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.