[GH-ISSUE #9154] Race condition in acceptInvitation creates duplicate member records #19922

Open
opened 2026-04-15 19:17:42 -05:00 by GiteaMirror · 0 comments
Owner

Originally created by @BengtHagemeister on GitHub (Apr 13, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/9154

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Call acceptInvitation twice concurrently with the same invitationId (this naturally happens in React Strict Mode during development, or under real network retries/double-clicks in production)
  2. Both calls succeed and create two separate member records for the same (organizationId, userId) pair

Current vs. Expected behavior

Current behavior:

The acceptInvitation endpoint in crud-invites.ts has a classic TOCTOU (time-of-check-time-of-use) race condition:

  1. Line 561–568: It reads the invitation and checks status === "pending"
  2. Line 629–632: It updates the invitation status to "accepted"
  3. Line 694–699: It calls adapter.createMember()

Two concurrent requests can both pass the status === "pending" check (step 1) before either has updated the status (step 2). Both then proceed to update the invitation and create separate member records, resulting in duplicate members for the same user in the same organization.

Additionally, there is no unique constraint on (organizationId, userId) in the member model, so the database does not catch these duplicates either.

Expected behavior:

  • acceptInvitation should be idempotent — calling it twice for the same invitation should not create duplicate members.
  • At minimum, one of the following should prevent duplicates:
    1. An atomic check-and-update on the invitation (e.g., UPDATE ... WHERE status = 'pending' and check affected rows) so only one concurrent request proceeds
    2. A unique constraint on (organizationId, userId) in the member table
    3. An existence check before createMember (e.g., check if a member with this userId + organizationId already exists)

Suggested fix

The most robust fix would combine two things:

  1. Atomic status update: Change updateInvitation in adapter.ts to conditionally update only if the current status is "pending":

    // adapter.ts - updateInvitation
    const invitation = await adapter.update({
      model: "invitation",
      where: [
        { field: "id", value: data.invitationId },
        { field: "status", value: "pending" }, // Only update if still pending
      ],
      update: { status: data.status },
    });
    // If invitation is null, another request already accepted/rejected it
    
  2. Guard before member creation: Add an existence check or use an upsert:

    // crud-invites.ts - before createMember
    const existingMember = await adapter.findMemberByOrgId({
      userId: session.user.id,
      organizationId: invitation.organizationId,
    });
    if (existingMember) {
      return ctx.json({ invitation: acceptedI, member: existingMember });
    }
    

Which area(s) are affected? (Select all that apply)

Backend

Additional context

This was discovered in a Next.js app where React Strict Mode double-mounts components in development, causing the useEffect that calls acceptInvitation to fire twice concurrently. While the client-side can be guarded with a ref, the server should be resilient to concurrent calls regardless.

The same pattern (non-atomic read-then-write without constraints) was previously identified in the JWK plugin (#5663) and invitation resend logic (#3507).

Originally created by @BengtHagemeister on GitHub (Apr 13, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/9154 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Call `acceptInvitation` twice concurrently with the same `invitationId` (this naturally happens in React Strict Mode during development, or under real network retries/double-clicks in production) 2. Both calls succeed and create two separate `member` records for the same `(organizationId, userId)` pair ### Current vs. Expected behavior **Current behavior:** The `acceptInvitation` endpoint in `crud-invites.ts` has a classic TOCTOU (time-of-check-time-of-use) race condition: 1. **Line 561–568**: It reads the invitation and checks `status === "pending"` 2. **Line 629–632**: It updates the invitation status to `"accepted"` 3. **Line 694–699**: It calls `adapter.createMember()` Two concurrent requests can both pass the `status === "pending"` check (step 1) before either has updated the status (step 2). Both then proceed to update the invitation and create separate member records, resulting in **duplicate members** for the same user in the same organization. Additionally, there is no unique constraint on `(organizationId, userId)` in the `member` model, so the database does not catch these duplicates either. **Expected behavior:** - `acceptInvitation` should be idempotent — calling it twice for the same invitation should not create duplicate members. - At minimum, one of the following should prevent duplicates: 1. An atomic check-and-update on the invitation (e.g., `UPDATE ... WHERE status = 'pending'` and check affected rows) so only one concurrent request proceeds 2. A unique constraint on `(organizationId, userId)` in the `member` table 3. An existence check before `createMember` (e.g., check if a member with this `userId` + `organizationId` already exists) ### Suggested fix The most robust fix would combine two things: 1. **Atomic status update**: Change `updateInvitation` in `adapter.ts` to conditionally update only if the current status is `"pending"`: ```ts // adapter.ts - updateInvitation const invitation = await adapter.update({ model: "invitation", where: [ { field: "id", value: data.invitationId }, { field: "status", value: "pending" }, // Only update if still pending ], update: { status: data.status }, }); // If invitation is null, another request already accepted/rejected it ``` 2. **Guard before member creation**: Add an existence check or use an upsert: ```ts // crud-invites.ts - before createMember const existingMember = await adapter.findMemberByOrgId({ userId: session.user.id, organizationId: invitation.organizationId, }); if (existingMember) { return ctx.json({ invitation: acceptedI, member: existingMember }); } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Additional context This was discovered in a Next.js app where React Strict Mode double-mounts components in development, causing the `useEffect` that calls `acceptInvitation` to fire twice concurrently. While the client-side can be guarded with a ref, the server should be resilient to concurrent calls regardless. The same pattern (non-atomic read-then-write without constraints) was previously identified in the JWK plugin (#5663) and invitation resend logic (#3507).
GiteaMirror added the organization label 2026-04-15 19:17:42 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#19922