Check the identity that accepted.
In a private investigation, compare the invitation’s intended identity, the signed-in account and the workspace ID. A browser already signed into another account can make a seemingly straightforward invitation confusing.
Do not trust a workspace ID, role or email supplied only by the browser. Bind the invitation to your server-side account rules and show a clear account-switch path when the identities do not match.
Commit acceptance and membership together.
If your model allows it, validate the invitation and create membership in one transaction. Do not consume the invitation first and hope that a later membership write succeeds. Make repeat acceptance safe.
await database.transaction(async tx => {
const invite = await tx.lockInvitation(tokenHash);
await requireValidRecipient(invite, signedInUser);
await tx.addMembershipOnce(invite.workspaceId, signedInUser.id);
await tx.markInvitationAccepted(invite.id);
});This is an implementation sketch. Your checks must cover expiry, revocation, intended identity, role constraints and existing membership.
Use the same permission check the app uses.
After acceptance, load a protected workspace resource as that user. Inspect stale membership caches or claims if the write succeeded but access is still denied. Avoid solving a stale permission cache by granting access to every authenticated user.
Compare the workspace and membership IDs with JSON diff using development data. The correct result includes a wrong-user denial, not just a successful happy path.
Test interrupted and repeated acceptance.
Exercise an expired invite, a revoked invite, a different signed-in account, an already-member account and a retry after an interrupted write. Confirm there is no accepted-without-membership state left behind.
Reference: OWASP object-level authorisation. The local failure lab includes an accepted invite with missing membership.