Invitation flow design: the edge cases that break team onboarding

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:

  1. 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.
  2. 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_by as the actual user ID.
  3. User is logged in as a different person entirely: show an error and require the invitee to log in to accept.
Never auto-accept an invitation for a user whose email does not match the invite email. An attacker who has a user's email address could send themselves an invite and then trick that user into clicking the link. Always require explicit confirmation when there is an email mismatch.

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.

← Back to blog Try Bastionary free →