[GH-ISSUE #3507] resend: true is creating a duplicate invite instead of reusing the existing one #26950

Closed
opened 2026-04-17 17:42:33 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @brunowego on GitHub (Jul 20, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/3507

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Go to the invites table
  2. Click on “Resend Invite” for an existing invitation
  3. A new invite is created instead of reusing the current one
  4. Multiple invite records are now visible for the same email

Current vs. Expected behavior

  • Expected: Calling organization.inviteMember({ ..., resend: true }) should reuse the existing invite and only send the email again.
  • Actual: A new invite record is being created, resulting in duplicates.

What version of Better Auth are you using?

1.3.1

Provide environment information

- OS: macOS Sonoma
- Browser: Chrome 125.0.0.0

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

Client

Auth config (if applicable)

import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { admin, organization, oneTap } from 'better-auth/plugins'
import { v4 as uuidv4 } from 'uuid'
import { type BetterAuthOptions, betterAuth } from 'better-auth'
import { cache } from 'react'
import { headers } from 'next/headers'

import db from '@acme/db'
import { sendEmail, EmailTemplate } from '@acme/email'

import senv from './lib/env/server'
import penv from './lib/env/client'
import { ac, roles } from './permissions'

export const config = {
  database: drizzleAdapter(db, {
    provider: 'pg',
    usePlural: true,
  }),
  socialProviders: {
    ...(senv.GOOGLE_CLIENT_ID && senv.GOOGLE_CLIENT_SECRET
      ? {
          google: {
            clientId: senv.GOOGLE_CLIENT_ID,
            clientSecret: senv.GOOGLE_CLIENT_SECRET,
            disableSignUp: senv.DISABLE_SIGNUP,
            mapProfileToUser: (profile) => {
              return {
                ...profile,
                firstName: profile.given_name,
                lastName: profile.family_name,
                image: profile.picture?.replace('=s96-c', '=s256-c'),
              }
            },
          },
        }
      : {}),
  },
  plugins: [
    admin({
      defaultRole: 'user',
      adminRoles: ['admin'],
    }),
    organization({
      ac,
      roles,
      sendInvitationEmail: async ({
        email,
        organization,
        inviter,
        invitation,
      }) => {
        await sendEmail(email, EmailTemplate.MemberInviteEmail, {
          workspaceName: organization.name,
          invitedByName: inviter.user.name,
          invitedByEmail: inviter.user.email,
          name: email,
          inviteLink: `${penv.NEXT_PUBLIC_APP_URL}/sign-in?redirectTo=/accept-invitation/${invitation.id}`,
        })
      },
    }),
    oneTap(),
  ],
  user: {
    additionalFields: {
      firstName: {
        type: 'string',
        fieldName: 'firstName',
      },
      lastName: {
        type: 'string',
        required: false,
        fieldName: 'lastName',
      },
      theme: {
        type: 'string',
        required: false,
        fieldName: 'theme',
      },
      role: {
        type: 'string',
        required: false,
        fieldName: 'role',
      },
    },
  },
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24, // 1 day
  },
  advanced: {
    cookiePrefix: 'acme',
    database: {
      generateId: () => uuidv4(),
    },
  },
  databaseHooks: {
    user: {
      create: {
        before: async (user) => {
          if (user.email.endsWith('@acme.com')) {
            return {
              data: {
                ...user,
                role: 'admin',
              },
            }
          }

          return {
            data: {
              ...user,
            },
          }
        },
      },
    },
  },
} satisfies BetterAuthOptions

const auth = betterAuth(config)

const getCurrentSession = cache(
  async () => await auth.api.getSession({ headers: await headers() }),
)

export { auth as default, getCurrentSession }

Additional context

  • Reproduced locally and in production
  • Not browser-specific
  • The resend button calls organization.inviteMember({ ..., resend: true })
  • If resend: true is not meant to be idempotent, documentation should clarify that the previous invite must be manually deleted first
Originally created by @brunowego on GitHub (Jul 20, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/3507 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Go to the invites table 2. Click on “Resend Invite” for an existing invitation 3. A new invite is created instead of reusing the current one 4. Multiple invite records are now visible for the same email ### Current vs. Expected behavior - Expected: Calling organization.inviteMember({ ..., resend: true }) should reuse the existing invite and only send the email again. - Actual: A new invite record is being created, resulting in duplicates. ### What version of Better Auth are you using? 1.3.1 ### Provide environment information ```bash - OS: macOS Sonoma - Browser: Chrome 125.0.0.0 ``` ### Which area(s) are affected? (Select all that apply) Client ### Auth config (if applicable) ```typescript import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { admin, organization, oneTap } from 'better-auth/plugins' import { v4 as uuidv4 } from 'uuid' import { type BetterAuthOptions, betterAuth } from 'better-auth' import { cache } from 'react' import { headers } from 'next/headers' import db from '@acme/db' import { sendEmail, EmailTemplate } from '@acme/email' import senv from './lib/env/server' import penv from './lib/env/client' import { ac, roles } from './permissions' export const config = { database: drizzleAdapter(db, { provider: 'pg', usePlural: true, }), socialProviders: { ...(senv.GOOGLE_CLIENT_ID && senv.GOOGLE_CLIENT_SECRET ? { google: { clientId: senv.GOOGLE_CLIENT_ID, clientSecret: senv.GOOGLE_CLIENT_SECRET, disableSignUp: senv.DISABLE_SIGNUP, mapProfileToUser: (profile) => { return { ...profile, firstName: profile.given_name, lastName: profile.family_name, image: profile.picture?.replace('=s96-c', '=s256-c'), } }, }, } : {}), }, plugins: [ admin({ defaultRole: 'user', adminRoles: ['admin'], }), organization({ ac, roles, sendInvitationEmail: async ({ email, organization, inviter, invitation, }) => { await sendEmail(email, EmailTemplate.MemberInviteEmail, { workspaceName: organization.name, invitedByName: inviter.user.name, invitedByEmail: inviter.user.email, name: email, inviteLink: `${penv.NEXT_PUBLIC_APP_URL}/sign-in?redirectTo=/accept-invitation/${invitation.id}`, }) }, }), oneTap(), ], user: { additionalFields: { firstName: { type: 'string', fieldName: 'firstName', }, lastName: { type: 'string', required: false, fieldName: 'lastName', }, theme: { type: 'string', required: false, fieldName: 'theme', }, role: { type: 'string', required: false, fieldName: 'role', }, }, }, session: { expiresIn: 60 * 60 * 24 * 7, // 7 days updateAge: 60 * 60 * 24, // 1 day }, advanced: { cookiePrefix: 'acme', database: { generateId: () => uuidv4(), }, }, databaseHooks: { user: { create: { before: async (user) => { if (user.email.endsWith('@acme.com')) { return { data: { ...user, role: 'admin', }, } } return { data: { ...user, }, } }, }, }, }, } satisfies BetterAuthOptions const auth = betterAuth(config) const getCurrentSession = cache( async () => await auth.api.getSession({ headers: await headers() }), ) export { auth as default, getCurrentSession } ``` ### Additional context - Reproduced locally and in production - Not browser-specific - The resend button calls organization.inviteMember({ ..., resend: true }) - If resend: true is not meant to be idempotent, documentation should clarify that the previous invite must be manually deleted first
GiteaMirror added the lockedbug labels 2026-04-17 17:42:33 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Jul 20, 2025):

The current behavior of organization.inviteMember({ ..., resend: true }) is to always create a new invite, even when one already exists, so it is not idempotent and results in duplicate invites. There is no built-in support or documented hook in the organization plugin to deduplicate invites or reuse an existing one when resending—this matches what you’re seeing in your environment. No documentation or issues clarify this behavior or suggest best practices for deduplication.

As a workaround, you can query for an existing pending invite for the email before calling inviteMember, and only call it if one doesn’t exist, or handle deduplication in your application logic. Alternatively, you can set the cancelPendingInvitationsOnReInvite option to true in the organization plugin config to automatically cancel previous pending invites when resending, but this will not reuse the invite—it just avoids multiple active invites for the same email.

If true idempotent resend support is important for your workflow, it may be worth requesting this as a feature or clarifying the documentation to reflect the current behavior.
Reference

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Join Discord Share on X

<!-- gh-comment-id:3094844059 --> @dosubot[bot] commented on GitHub (Jul 20, 2025): <!-- Answer --> The current behavior of organization.inviteMember({ ..., resend: true }) is to always create a new invite, even when one already exists, so it is not idempotent and results in duplicate invites. There is no built-in support or documented hook in the organization plugin to deduplicate invites or reuse an existing one when resending—this matches what you’re seeing in your environment. No documentation or issues clarify this behavior or suggest best practices for deduplication. As a workaround, you can query for an existing pending invite for the email before calling inviteMember, and only call it if one doesn’t exist, or handle deduplication in your application logic. Alternatively, you can set the cancelPendingInvitationsOnReInvite option to true in the organization plugin config to automatically cancel previous pending invites when resending, but this will not reuse the invite—it just avoids multiple active invites for the same email. If true idempotent resend support is important for your workflow, it may be worth requesting this as a feature or clarifying the documentation to reflect the current behavior. [Reference](https://github.com/better-auth/better-auth/blob/99460c6325fef3661bc990826e8ae71933516330/packages/better-auth/src/plugins/organization/types.ts) <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/87bd649f-0e66-40fa-9399-69822de46c59?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/87bd649f-0e66-40fa-9399-69822de46c59?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/87bd649f-0e66-40fa-9399-69822de46c59?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/87bd649f-0e66-40fa-9399-69822de46c59?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/87bd649f-0e66-40fa-9399-69822de46c59?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/87bd649f-0e66-40fa-9399-69822de46c59?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/87bd649f-0e66-40fa-9399-69822de46c59?feedback_type=other)</sup>&nbsp;&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/3507)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#26950