Team invitations: secure org invite flows that don't leak user existence

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,
  });
});
Do not confirm whether an email has an existing account when displaying the invite acceptance page. Always render the same page regardless. The branching (login vs register) should happen after the user submits their action, and even then, error messages must not reveal account existence to a third party who obtained the invite link.

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.

← Back to blog Try Bastionary free →