An invitation system sits at the intersection of UX and security in an uncomfortable way. The UX ideal is frictionless: an admin enters an email, the invite lands in the inbox, one click and the invitee is in the org. The security constraints are real: invite tokens should not be guessable, accepting an invite should not silently overwrite an existing account's organization membership, and the flow should not confirm whether a given email address has an account on your platform.
Signed invite tokens
The most common mistake in invitation systems is storing the invite token in the database as a plain random string, then performing a database lookup on it. This works, but signed tokens let you validate the invite structurally before hitting the database at all — catching malformed or expired tokens at the edge.
import { SignJWT, jwtVerify } from 'jose';
import { createSecretKey } from 'crypto';
const INVITE_SECRET = createSecretKey(Buffer.from(process.env.INVITE_TOKEN_SECRET, 'hex'));
// Issue an invite token
async function createInvite(orgId, invitedEmail, role, invitedByUserId) {
const inviteId = crypto.randomUUID();
// Persist invite record to DB (for revocation and lookup)
await db.invites.create({
id: inviteId,
orgId,
email: invitedEmail.toLowerCase(),
role,
invitedBy: invitedByUserId,
status: 'pending',
expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000), // 7 days
});
// Sign a JWT that encodes the invite ID and key fields
const token = await new SignJWT({ inviteId, orgId, email: invitedEmail.toLowerCase() })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(INVITE_SECRET);
return token;
}
// Verify and accept an invite
async function acceptInvite(token, acceptingUserId) {
let payload;
try {
const result = await jwtVerify(token, INVITE_SECRET);
payload = result.payload;
} catch {
throw new Error('invite_token_invalid_or_expired');
}
const invite = await db.invites.findOne({
id: payload.inviteId,
status: 'pending',
});
if (!invite) throw new Error('invite_not_found_or_already_used');
if (new Date() > invite.expiresAt) throw new Error('invite_expired');
// Verify the accepting user's email matches the invite
const user = await db.users.findById(acceptingUserId);
if (user.email !== invite.email) {
throw new Error('invite_email_mismatch');
}
// Atomic: mark invite used and add org membership
await db.transaction(async (tx) => {
await tx.invites.update({ id: invite.id }, { status: 'accepted', acceptedAt: new Date() });
await tx.orgMembers.create({ orgId: invite.orgId, userId: acceptingUserId, role: invite.role });
});
}
The accept-creates-account flow
When the invited email belongs to a user who has not yet registered, the invite link should bootstrap their account creation. The flow is: user clicks invite link, lands on a registration page pre-filled with their email (which they cannot change), completes registration, and is immediately added to the org.
// GET /invite/accept?token=...
app.get('/invite/accept', async (req, res) => {
const { token } = req.query;
let payload;
try {
const result = await jwtVerify(token, INVITE_SECRET);
payload = result.payload;
} catch {
return res.redirect('/invite/invalid');
}
const invite = await db.invites.findOne({ id: payload.inviteId, status: 'pending' });
if (!invite || new Date() > invite.expiresAt) {
return res.redirect('/invite/expired');
}
const existingUser = await db.users.findByEmail(invite.email);
if (existingUser) {
// User exists: if logged in as them, accept immediately
// If logged in as someone else, show "switch account" prompt
// If not logged in, redirect to login with invite token in state
if (req.user?.id === existingUser.id) {
await acceptInvite(token, req.user.id);
return res.redirect(`/org/${invite.orgId}/welcome`);
}
req.session.pendingInviteToken = token;
return res.redirect('/login?hint=' + encodeURIComponent(invite.email));
}
// No account: show registration form, pre-fill email, lock it
req.session.pendingInviteToken = token;
res.render('invite-register', {
email: invite.email,
orgName: invite.orgName,
});
});
Resend logic and rate limiting
Resending an invite should invalidate the previous token and issue a new one. Storing both tokens creates a window where either could be used, complicates auditing, and provides no benefit. When an admin clicks "Resend invite", mark the old invite as superseded and issue a new one with a fresh expiry.
async function resendInvite(inviteId, requestingAdminId) {
const invite = await db.invites.findOne({ id: inviteId, status: 'pending' });
if (!invite) throw new Error('invite_not_found');
// Rate limit: one resend per 5 minutes per invite
const lastResent = invite.lastResentAt;
if (lastResent && Date.now() - lastResent.getTime() < 5 * 60 * 1000) {
throw new Error('resend_too_soon');
}
// Invalidate old invite and create fresh one
await db.invites.update({ id: inviteId }, { status: 'superseded' });
return createInvite(invite.orgId, invite.email, invite.role, requestingAdminId);
}
Domain allow-lists
Enterprise customers with a managed email domain often want to lock their organization to a specific domain: only @acme.com addresses can join. Domain allow-lists let org admins configure this constraint. An incoming invite or join request is rejected if the email domain is not in the allowed list.
async function checkDomainPolicy(orgId, email) {
const org = await db.orgs.findById(orgId);
if (!org.domainAllowList || org.domainAllowList.length === 0) {
return { allowed: true }; // No restriction configured
}
const domain = email.split('@')[1]?.toLowerCase();
if (!org.domainAllowList.includes(domain)) {
return {
allowed: false,
reason: `Only @${org.domainAllowList.join(', @')} addresses can join this organization.`,
};
}
return { allowed: true };
}
Domain allow-lists are also useful in the opposite direction: allowing any user with a corporate email address to join without an explicit invite. An org can configure auto_join_domains: ["acme.com"] so that any user who verifies an @acme.com address is automatically added to the org with a default role. This reduces admin overhead for large organizations while keeping the org private from everyone else.