Invitation flows are a privileged code path: they grant access to a tenant's data before the invitee is a verified member. CWE-330 (Insufficient Randomness) allows token prediction; CWE-613 (Insufficient Session Expiration) keeps compromised tokens valid indefinitely; CWE-285 allows a token issued by Org A to be accepted against Org B. OWASP A07 (Identification & Authentication Failures) covers all three. The real-world consequence is that an attacker who intercepts or guesses an invitation token can join any organization without being explicitly invited.
High because a guessable or non-expiring invitation token lets an attacker join an arbitrary tenant's workspace without authorization from any member of that tenant.
Use cryptographically random tokens, enforce expiration, and validate the token's organization at acceptance time:
// Generation — in src/lib/invitations.ts
const token = crypto.randomUUID() // or randomBytes(32).toString('hex') — 128+ bits of entropy
await db.invitation.create({
data: {
token,
email: invitedEmail,
organizationId: currentOrgId,
expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000) // 72-hour window
}
})
// Acceptance — validate all constraints atomically
const invite = await db.invitation.findFirst({
where: {
token,
organizationId: params.orgId, // must match the org in the URL
expiresAt: { gt: new Date() }, // must not be expired
acceptedAt: null // must be unused
}
})
if (!invite) return new Response('Invalid or expired invitation', { status: 400 })
if (invite.email.toLowerCase() !== session.user.email.toLowerCase()) {
return new Response('This invitation is for a different email address', { status: 403 })
}
ID: saas-multi-tenancy.shared-resources.invitation-validates-membership
Severity: high
What to look for: Examine the invitation flow — how users are invited to join a team/org, how invitation tokens are generated, how they are validated when accepted, and how accepted invitations grant access. Check for: (a) whether invitation tokens are cryptographically random and not guessable, (b) whether accepting an invitation verifies the token belongs to the correct organization, (c) whether invitation tokens expire, and (d) whether an invitation token can be used to join a different organization than the one that issued it.
Pass criteria: List all invitation-acceptance paths. Invitation tokens are cryptographically random (UUID v4 or equivalent, at least 128 bits of entropy). Accepting an invitation verifies that the token exists and that the accepting user's email matches the invited email (or that the token is single-use). Tokens expire after no more than 72 hours (24-72 hours is standard). A user cannot use an invitation token to join an organization other than the one that issued the invitation.
Fail criteria: Invitation tokens are sequential, predictable, or otherwise guessable. Accepting an invitation does not verify the token belongs to the target organization. Invitation tokens do not expire. An invitation to Org A can be accepted to join Org B by manipulating the acceptance request.
Skip (N/A) when: No invitation system is detected. Signal: no invitation table/model, no invitation email sending, no invite token generation or validation code.
Detail on fail: Describe the vulnerability. Example: "Invitation tokens in src/lib/invitations.ts are generated as Math.random().toString(36) — not cryptographically secure. No expiration is set. Acceptance endpoint does not verify token's organizationId matches the URL's orgId parameter."
Remediation: Use cryptographically secure tokens with expiration and strict validation:
// Generation
const token = crypto.randomUUID() // or: randomBytes(32).toString('hex')
await db.invitation.create({
data: {
token,
email: invitedEmail,
organizationId: currentOrgId,
expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000) // 72 hours
}
})
// Acceptance
const invite = await db.invitation.findFirst({
where: {
token,
organizationId: params.orgId, // must match URL
expiresAt: { gt: new Date() }, // must not be expired
acceptedAt: null // must not already be used
}
})
if (!invite) return new Response('Invalid or expired invitation', { status: 400 })
// Verify invitee email matches
if (invite.email.toLowerCase() !== session.user.email.toLowerCase()) {
return new Response('This invitation is for a different email address', { status: 403 })
}