The invite-a-teammate flow seems simple: send an email with a link, user clicks it, they join. In practice it is one of the most bug-prone flows in any SaaS product because it spans time (invites expire), crosses contexts (the invitee may already have an account with a different email), and intersects with complex enterprise identity configurations (SSO, SCIM, domain-based auto-join). Get it wrong and new users hit confusing error messages on their first interaction with your product.
The invitation data model
CREATE TABLE invitations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organizations(id), invitee_email TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', invited_by UUID NOT NULL REFERENCES users(id), token_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the raw token created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, -- typically NOW() + INTERVAL '7 days' accepted_at TIMESTAMPTZ, accepted_by UUID REFERENCES users(id), -- may differ from invitee if email changed revoked_at TIMESTAMPTZ, resent_count INT NOT NULL DEFAULT 0, last_resent_at TIMESTAMPTZ ); CREATE UNIQUE INDEX ON invitations (org_id, invitee_email) WHERE accepted_at IS NULL AND revoked_at IS NULL; -- one pending invite per email per org
Token security
The invite token is a capability token — anyone who has it can join the org. It must be unguessable and single-use. Use 32 bytes of cryptographic randomness encoded as base64url (43 characters), and store only the SHA-256 hash in the database. The raw token appears once: in the invitation email link. If the database is compromised, the hashed tokens cannot be used to accept invitations.
import crypto from 'crypto';
function generateInviteToken() {
return crypto.randomBytes(32).toString('base64url');
}
function hashInviteToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
// Accept invite endpoint
async function acceptInvitation(rawToken, acceptingUserId) {
const tokenHash = hashInviteToken(rawToken);
const invite = await db.query(`
SELECT * FROM invitations
WHERE token_hash = $1
AND accepted_at IS NULL
AND revoked_at IS NULL
AND expires_at > NOW()
FOR UPDATE
`, [tokenHash]);
if (!invite.rows[0]) {
// Don't reveal whether the token was valid, expired, or already used
throw new Error('This invitation link is invalid or has expired.');
}
const inv = invite.rows[0];
// Check if accepting user's email matches invite
const user = await getUserById(acceptingUserId);
if (user.email.toLowerCase() !== inv.invitee_email.toLowerCase()) {
// Accepting with a different email — see "email mismatch" section below
await handleEmailMismatch(inv, user);
}
// Add to org
await db.query(`
INSERT INTO org_members (org_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (org_id, user_id) DO UPDATE SET role = $3
`, [inv.org_id, acceptingUserId, inv.role]);
// Mark invite as accepted
await db.query(`
UPDATE invitations
SET accepted_at = NOW(), accepted_by = $2
WHERE id = $1
`, [inv.id, acceptingUserId]);
}
Email mismatch: the hardest edge case
The invitee email in the invite record is the address you sent the link to. The user who clicks the link may be logged into your app with a different email address. Three scenarios:
- User is not logged in: prompt them to log in or create an account. Pre-fill the email from the invite. If they create an account with a different email, warn them and offer to match by email.
- User is logged in with a different email, same person: the user has two accounts or changed their email. Show a confirmation: "This invite was sent to alice@oldcompany.com but you are logged in as alice@newcompany.com. Accept anyway?" Record
accepted_byas the actual user ID. - User is logged in as a different person entirely: show an error and require the invitee to log in to accept.
Token expiry and resend
Seven days is a common default expiry for invitation tokens. Too short (24 hours) and busy people miss invites. Too long (30 days) and stale invites pile up. Allow org admins to resend invitations — this generates a new token, invalidates the old one, and extends the expiry. Rate-limit resends to prevent abuse.
async function resendInvitation(inviteId, adminUserId) {
const invite = await db.query(
'SELECT * FROM invitations WHERE id = $1 AND accepted_at IS NULL AND revoked_at IS NULL',
[inviteId]
);
if (!invite.rows[0]) throw new Error('Invitation not found');
const inv = invite.rows[0];
// Rate limit: max 3 resends, not more than once per hour
if (inv.resent_count >= 3) throw new Error('Maximum resend limit reached');
if (inv.last_resent_at && Date.now() - new Date(inv.last_resent_at) < 3600000) {
throw new Error('Please wait before resending');
}
// Generate new token, extend expiry
const newToken = generateInviteToken();
const newHash = hashInviteToken(newToken);
await db.query(`
UPDATE invitations
SET token_hash = $2,
expires_at = NOW() + INTERVAL '7 days',
resent_count = resent_count + 1,
last_resent_at = NOW()
WHERE id = $1
`, [inviteId, newHash]);
await sendInvitationEmail(inv.invitee_email, newToken, inv.org_id);
return true;
}
Domain-based bulk invite and verified domains
Enterprise customers often want to allow anyone with a @company.com email to join their org without an individual invite. This requires domain ownership verification: you must prove the customer controls the domain before enabling auto-join, otherwise an attacker could configure @gmail.com as their org domain and auto-join all Gmail users.
-- Domain verification: customer adds a DNS TXT record to prove ownership
CREATE TABLE verified_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(id),
domain TEXT NOT NULL,
verified_at TIMESTAMPTZ,
auto_join BOOLEAN DEFAULT FALSE, -- allow email signup without invite
auto_role TEXT DEFAULT 'member',
verify_token TEXT NOT NULL, -- value customer adds to TXT record
UNIQUE(domain)
);
-- At signup: check if user's email domain has auto-join enabled
async function checkDomainAutoJoin(email) {
const domain = email.split('@')[1].toLowerCase();
const result = await db.query(`
SELECT org_id, auto_role
FROM verified_domains
WHERE domain = $1
AND verified_at IS NOT NULL
AND auto_join = TRUE
`, [domain]);
return result.rows[0] || null;
}
SSO invite handling
When an org uses SSO, individual invitations conflict with the SSO flow. A user invited to alice@acme.com who tries to log in via Acme's Okta will be provisioned through SSO, not through the invite link. The invite link effectively becomes a pre-authorization that grants the role specified in the invite when the SSO session is created.
Implementation: when SSO creates or updates a user, check for a pending invite for that user's email in the authenticating org. If found, accept the invite automatically using the SSO user's identity. The user lands in the app with the correct role without ever needing to click the invite link directly. This is the expected behavior for enterprise SSO: the IdP is the source of truth for provisioning, and the invite is just a role pre-authorization.