[GH-ISSUE #8826] Owner cannot invite another user with owner role via inviteMember #19834

Open
opened 2026-04-15 19:11:10 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @soham2k06 on GitHub (Mar 29, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/8826

Originally assigned to: @ping-maxwell on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Create an organization (you become the owner), creatorRole is unchanged.
  2. Call authClient.organization.inviteMember({ email, role: "owner", organizationId })
  3. Observe the FORBIDDEN error

Current vs. Expected behavior

Current behavior:
Owner cannot invite another user with owner role via inviteMember

Expected behavior:
An owner should be able to invite another user as an owner.
Only non-owners should be blocked from granting the owner role.

What version of Better Auth are you using?

^1.5.3

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.2.0: Tue Nov 18 21:09:55 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T8103",
    "release": "25.2.0",
    "cpuCount": 8,
    "cpuModel": "Apple M1",
    "totalMemory": "8.00 GB",
    "freeMemory": "0.17 GB"
  },
  "node": {
    "version": "v24.14.0",
    "env": "development"
  },
  "packageManager": {
    "name": "pnpm",
    "version": "8.14.1"
  },
  "frameworks": [
    {
      "name": "fastify",
      "version": "^5.7.4"
    }
  ],
  "databases": [
    {
      "name": "@prisma/client",
      "version": "^7.4.1"
    }
  ],
  "betterAuth": {
    "version": "^1.5.3",
    "config": null
  }
}

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

Backend

Auth config (if applicable)

import { betterAuth } from "better-auth";
import { openAPI, organization } from "better-auth/plugins";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { FastifyInstance } from "fastify/types/instance";

export const createAuth = (app: FastifyInstance) =>
  betterAuth({
    basePath: "/auth",
    baseURL: app.config.BETTER_AUTH_URL || "http://localhost:4000",

    rateLimit: {
      enabled: true,
      storage: "secondary-storage",
      customRules: {
        "/request-reset-password": {
          max: 5,
          window: 60 * 60, // 1 hour
        },
        "/send-verification-email": {
          max: 5,
          window: 60 * 60, // 1 hour
        },
      },
    },

    socialProviders: {
      google: {
        clientId: app.config.GOOGLE_CLIENT_ID as string,
        clientSecret: app.config.GOOGLE_CLIENT_SECRET as string,
      },
    },

    emailAndPassword: {
      enabled: true,

      requireEmailVerification: true,
      autoSignIn: true,

      sendResetPassword: async ({ token }) => {
        const url = `${app.config.BETTER_AUTH_URL}/auth/reset-password?token=${token}`;
        void app.resend.emails.send({
          from: "onboarding@resend.dev",
          to: app.config.DEV_EMAIL, // TODO: it should reach to user.email but before we buy domain, use our dev email where we configured resend to receive email
          subject: "Reset your password",
          text: `Click the link to reset your password: ${url}`,
        });
      },
    },

    emailVerification: {
      sendVerificationEmail: async (data) => {
        void app.resend.emails.send({
          from: "onboarding@resend.dev",
          // TODO: it should reach to data.user.email but before we buy domain, use our dev email where we configured resend to receive email
          to: app.config.DEV_EMAIL,
          subject: "Verify your email",
          html: `<p>Click <a href="${data.url}">here</a> to verify your email.</p>`,
        });
      },

      autoSignInAfterVerification: true,
    },

    plugins: [
      organization({
        allowInviteByRole: ["owner", "admin"],
        sendInvitationEmail: async (data) => {
          const url = `${app.config.CLIENT_ORIGIN}/auth/accept-invitation?token=${data.id}`;

          void app.resend.emails.send({
            from: "onboarding@resend.dev",
            to: app.config.DEV_EMAIL,
            subject: `You've been invited to join the ${data.organization.name} by ${data.inviter.user.name}`,
            html: `<p>Click <a href="${url}">here</a> to join the organization.</p>`,
          });
        },
      }),

      openAPI({
        path: "/docs",
        theme: "deepSpace",
      }),
    ],

    database: prismaAdapter(app.prisma, {
      provider: "postgresql",
    }),

    databaseHooks: {
      session: {
        create: {
          before: async (session) => {
            const firstOrg = await app.prisma.member.findFirst({
              where: {
                userId: session.userId,
              },
              select: {
                organization: { select: { id: true } },
              },
            });

            return {
              data: {
                ...session,
                activeOrganizationId: firstOrg?.organization.id,
              },
            };
          },
        },
      },
    },

    secondaryStorage: {
      get: async (key) => await app.redis.get(key),
      set: async (key, value, ttl) => {
        if (ttl) await app.redis.set(key, value, "EX", ttl);
        else await app.redis.set(key, value);
      },
      delete: async (key) => {
        await app.redis.del(key);
      },
    },

    trustedOrigins: ["http://localhost:3000"],
    advanced: {
      disableOriginCheck: app.config.NODE_ENV === "development",
    },
  });

export type AuthInstance = ReturnType<typeof createAuth>;

Additional context

No response

Originally created by @soham2k06 on GitHub (Mar 29, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/8826 Originally assigned to: @ping-maxwell on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Create an organization (you become the `owner`), creatorRole is unchanged. 2. Call `authClient.organization.inviteMember({ email, role: "owner", organizationId })` 3. Observe the `FORBIDDEN` error ### Current vs. Expected behavior Current behavior: Owner cannot invite another user with `owner` role via `inviteMember` Expected behavior: An owner should be able to invite another user as an owner. Only non-owners should be blocked from granting the owner role. ### What version of Better Auth are you using? ^1.5.3 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.2.0: Tue Nov 18 21:09:55 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T8103", "release": "25.2.0", "cpuCount": 8, "cpuModel": "Apple M1", "totalMemory": "8.00 GB", "freeMemory": "0.17 GB" }, "node": { "version": "v24.14.0", "env": "development" }, "packageManager": { "name": "pnpm", "version": "8.14.1" }, "frameworks": [ { "name": "fastify", "version": "^5.7.4" } ], "databases": [ { "name": "@prisma/client", "version": "^7.4.1" } ], "betterAuth": { "version": "^1.5.3", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth"; import { openAPI, organization } from "better-auth/plugins"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { FastifyInstance } from "fastify/types/instance"; export const createAuth = (app: FastifyInstance) => betterAuth({ basePath: "/auth", baseURL: app.config.BETTER_AUTH_URL || "http://localhost:4000", rateLimit: { enabled: true, storage: "secondary-storage", customRules: { "/request-reset-password": { max: 5, window: 60 * 60, // 1 hour }, "/send-verification-email": { max: 5, window: 60 * 60, // 1 hour }, }, }, socialProviders: { google: { clientId: app.config.GOOGLE_CLIENT_ID as string, clientSecret: app.config.GOOGLE_CLIENT_SECRET as string, }, }, emailAndPassword: { enabled: true, requireEmailVerification: true, autoSignIn: true, sendResetPassword: async ({ token }) => { const url = `${app.config.BETTER_AUTH_URL}/auth/reset-password?token=${token}`; void app.resend.emails.send({ from: "onboarding@resend.dev", to: app.config.DEV_EMAIL, // TODO: it should reach to user.email but before we buy domain, use our dev email where we configured resend to receive email subject: "Reset your password", text: `Click the link to reset your password: ${url}`, }); }, }, emailVerification: { sendVerificationEmail: async (data) => { void app.resend.emails.send({ from: "onboarding@resend.dev", // TODO: it should reach to data.user.email but before we buy domain, use our dev email where we configured resend to receive email to: app.config.DEV_EMAIL, subject: "Verify your email", html: `<p>Click <a href="${data.url}">here</a> to verify your email.</p>`, }); }, autoSignInAfterVerification: true, }, plugins: [ organization({ allowInviteByRole: ["owner", "admin"], sendInvitationEmail: async (data) => { const url = `${app.config.CLIENT_ORIGIN}/auth/accept-invitation?token=${data.id}`; void app.resend.emails.send({ from: "onboarding@resend.dev", to: app.config.DEV_EMAIL, subject: `You've been invited to join the ${data.organization.name} by ${data.inviter.user.name}`, html: `<p>Click <a href="${url}">here</a> to join the organization.</p>`, }); }, }), openAPI({ path: "/docs", theme: "deepSpace", }), ], database: prismaAdapter(app.prisma, { provider: "postgresql", }), databaseHooks: { session: { create: { before: async (session) => { const firstOrg = await app.prisma.member.findFirst({ where: { userId: session.userId, }, select: { organization: { select: { id: true } }, }, }); return { data: { ...session, activeOrganizationId: firstOrg?.organization.id, }, }; }, }, }, }, secondaryStorage: { get: async (key) => await app.redis.get(key), set: async (key, value, ttl) => { if (ttl) await app.redis.set(key, value, "EX", ttl); else await app.redis.set(key, value); }, delete: async (key) => { await app.redis.del(key); }, }, trustedOrigins: ["http://localhost:3000"], advanced: { disableOriginCheck: app.config.NODE_ENV === "development", }, }); export type AuthInstance = ReturnType<typeof createAuth>; ``` ### Additional context _No response_
GiteaMirror added the awaiting external contributorbugorganization labels 2026-04-15 19:11:10 -05:00
Author
Owner

@Oluwatobi-Mustapha commented on GitHub (Mar 31, 2026):

Hi @himself65 @Bekacru I will like to take this. I fixed something related to it recently in Keycloak.

Permission to proceed?

<!-- gh-comment-id:4161303368 --> @Oluwatobi-Mustapha commented on GitHub (Mar 31, 2026): Hi @himself65 @Bekacru I will like to take this. I fixed something related to it recently in Keycloak. Permission to proceed?
Author
Owner

@ping-maxwell commented on GitHub (Mar 31, 2026):

Hey @Oluwatobi-Mustapha, go for it 🙌

<!-- gh-comment-id:4162085235 --> @ping-maxwell commented on GitHub (Mar 31, 2026): Hey @Oluwatobi-Mustapha, go for it 🙌
Author
Owner

@Oluwatobi-Mustapha commented on GitHub (Mar 31, 2026):

Thanks, @ping-maxwell. I’m on it.

<!-- gh-comment-id:4162300954 --> @Oluwatobi-Mustapha commented on GitHub (Mar 31, 2026): Thanks, @ping-maxwell. I’m on it.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#19834