[GH-ISSUE #3651] Organization can have no owner or members #9674

Closed
opened 2026-04-13 05:17:39 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @ebrenner-code on GitHub (Jul 27, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/3651

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Install sveltekit
  2. Install better-auth
  3. Install organization from better-auth/plugins
  4. implement adding/removing organization members and changing their roles

Current vs. Expected behavior

  1. After creating an organization, if I try to remove myself from that org (as the owner), I get the error.message: "You cannot leave the organization as the only owner" That's great and expected behavior and a reasonable default for 99.9% of use cases.

  2. Ok, so then I change my own role, as the only member of the org to 'admin' and that works. This is the issue. I don't believe you should be able to have an organization with no owner. Now the org has no owner. I cannot change myself back to an owner. I get error.message "You are not allowed to update this member" which makes sense

  3. If I then try to remove myself from the org as an admin, I get the error.message "You are not a member of this organization" this is a strange error because really it was a success, I was removed from the organization, but I probably should not have been able to do that because there is now an org in the database with no members.

So I think the fix here, that would be the expected behavior and suit almost every setup is: Do not allow owners to change their role unless there is another owner. This will also fix the problem of organizations with no members, because as the sole owner, I can't remove myself I have to delete the team.

What version of Better Auth are you using?

1.3.4

Provide environment information

svelte@5.28.2
@sveltejs/kit@2.26.1

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

Client

Auth config (if applicable)

export const auth = betterAuth({
	appName: PUBLIC_SITE_NAME,
	database: drizzleAdapter(db, {
		provider: 'pg' // or "mysql", "sqlite"
	}),
	emailAndPassword: {
		enabled: true,
		sendResetPassword: async ({ user, url, token }, request) => {
			await sendEmail({
				to: user.email,
				subject: 'Reset your password',
				html: `Click the link to reset your password: ${url}`
			});
		},
		minPasswordLength: Number(PUBLIC_MIN_PASSWORD_LENGTH),
		maxPasswordLength: Number(PUBLIC_MAX_PASSWORD_LENGTH)
	},
	socialProviders: {
		google: {
			prompt: 'select_account',
			clientId: GOOGLE_CLIENT_ID,
			clientSecret: GOOGLE_CLIENT_SECRET
		},
		facebook: {
			clientId: FACEBOOK_APP_ID,
			clientSecret: FACEBOOK_APP_SECRET as string
		}
	},
	emailVerification: {
		sendVerificationEmail: async ({ user, url, token }, request) => {
			await sendEmail({
				to: user.email,
				subject: 'Verify Your Email',
				html: `Click the link to verify your email: ${url}`
			});
		}
	},
	plugins: [
		sveltekitCookies(getRequestEvent),
		twoFactor(),
		organization({
			async sendInvitationEmail(data) {
				const inviteLink = `${PUBLIC_SITE_URL}/accept-invite/${data.id}`;
				sendEmail({
					to: data.email,
					subject: `You've been invited to ${data.organization.name}`,
					html: `To accept this invitation, follow this link: ${inviteLink}`
				});
			}
		}),
		admin({
			adminUserIds: [SUPER_ADMIN_USER_ID]
		}),
		passkey({
			rpName: PUBLIC_SITE_NAME,
			origin: PUBLIC_SITE_URL
		}),
		emailOTP({
			disableSignUp: true,
			expiresIn,
			async sendVerificationOTP({ email, otp, type }) {
				let subject = '';
				let html = '';
				switch (type) {
					case 'sign-in':
						subject = 'One-Time Login Code';
						html = `
							<p>Here is your one-time login code: ${otp}</p>
							<p>Or you can follow this link: <a href="${PUBLIC_SITE_URL}/one-time-password?email=${email}&otp=${otp}">${PUBLIC_SITE_URL}/one-time-password?email=${email}&otp=${otp}</a>
							<p>This code will expire in ${expiresInMinutes} minutes.</p>
						`;
						break;
				}
				await sendEmail({
					to: email,
					subject,
					html
				});
			}
		})
	]
});

Additional context

No response

Originally created by @ebrenner-code on GitHub (Jul 27, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/3651 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Install sveltekit 2. Install better-auth 3. Install organization from better-auth/plugins 4. implement adding/removing organization members and changing their roles ### Current vs. Expected behavior 1. After creating an organization, if I try to remove myself from that org (as the owner), I get the error.message: "You cannot leave the organization as the only owner" That's great and expected behavior and a reasonable default for 99.9% of use cases. 2. Ok, so then I change my own role, as the only member of the org to 'admin' and that works. This is the issue. I don't believe you should be able to have an organization with no owner. Now the org has no owner. I cannot change myself back to an owner. I get error.message "You are not allowed to update this member" which makes sense 3. If I then try to remove myself from the org as an admin, I get the error.message "You are not a member of this organization" this is a strange error because really it was a success, I was removed from the organization, but I probably should not have been able to do that because there is now an org in the database with no members. So I think the fix here, that would be the expected behavior and suit almost every setup is: Do not allow owners to change their role unless there is another owner. This will also fix the problem of organizations with no members, because as the sole owner, I can't remove myself I have to delete the team. ### What version of Better Auth are you using? 1.3.4 ### Provide environment information ```bash svelte@5.28.2 @sveltejs/kit@2.26.1 ``` ### Which area(s) are affected? (Select all that apply) Client ### Auth config (if applicable) ```typescript export const auth = betterAuth({ appName: PUBLIC_SITE_NAME, database: drizzleAdapter(db, { provider: 'pg' // or "mysql", "sqlite" }), emailAndPassword: { enabled: true, sendResetPassword: async ({ user, url, token }, request) => { await sendEmail({ to: user.email, subject: 'Reset your password', html: `Click the link to reset your password: ${url}` }); }, minPasswordLength: Number(PUBLIC_MIN_PASSWORD_LENGTH), maxPasswordLength: Number(PUBLIC_MAX_PASSWORD_LENGTH) }, socialProviders: { google: { prompt: 'select_account', clientId: GOOGLE_CLIENT_ID, clientSecret: GOOGLE_CLIENT_SECRET }, facebook: { clientId: FACEBOOK_APP_ID, clientSecret: FACEBOOK_APP_SECRET as string } }, emailVerification: { sendVerificationEmail: async ({ user, url, token }, request) => { await sendEmail({ to: user.email, subject: 'Verify Your Email', html: `Click the link to verify your email: ${url}` }); } }, plugins: [ sveltekitCookies(getRequestEvent), twoFactor(), organization({ async sendInvitationEmail(data) { const inviteLink = `${PUBLIC_SITE_URL}/accept-invite/${data.id}`; sendEmail({ to: data.email, subject: `You've been invited to ${data.organization.name}`, html: `To accept this invitation, follow this link: ${inviteLink}` }); } }), admin({ adminUserIds: [SUPER_ADMIN_USER_ID] }), passkey({ rpName: PUBLIC_SITE_NAME, origin: PUBLIC_SITE_URL }), emailOTP({ disableSignUp: true, expiresIn, async sendVerificationOTP({ email, otp, type }) { let subject = ''; let html = ''; switch (type) { case 'sign-in': subject = 'One-Time Login Code'; html = ` <p>Here is your one-time login code: ${otp}</p> <p>Or you can follow this link: <a href="${PUBLIC_SITE_URL}/one-time-password?email=${email}&otp=${otp}">${PUBLIC_SITE_URL}/one-time-password?email=${email}&otp=${otp}</a> <p>This code will expire in ${expiresInMinutes} minutes.</p> `; break; } await sendEmail({ to: email, subject, html }); } }) ] }); ``` ### Additional context _No response_
GiteaMirror added the locked label 2026-04-13 05:17:39 -05:00
Author
Owner

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

Hi @ebrenner-code! I'm Dosu and I’m helping the better-auth team.

You're right—currently, better-auth prevents the last owner from leaving an organization, but it does not prevent the last owner from changing their role to a non-owner, which can leave the org with no owners or even no members. This is a gap in the logic: the role-change handler doesn't enforce the "at least one owner" rule, only the removal handler does. This means an organization can end up orphaned if the sole owner demotes themselves, which is a data integrity issue and not typical for org/team management systems see analysis.

Until this is fixed in the plugin, you can add a check at the application layer: before allowing a role change from "owner" to any other role, query the org's members and count the owners. If there's only one, block the change and prompt the user to add another owner first. This mirrors the removal logic and will prevent orphaned organizations.

This is a known gap and worth watching for updates, as the codebase is modular and the fix is likely to land in the member update logic in the plugin's route handlers.

To reply, just mention @dosu.


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

<!-- gh-comment-id:3124685156 --> @dosubot[bot] commented on GitHub (Jul 27, 2025): <!-- Greeting --> Hi @ebrenner-code! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> You're right—currently, better-auth prevents the last owner from leaving an organization, but it does not prevent the last owner from changing their role to a non-owner, which can leave the org with no owners or even no members. This is a gap in the logic: the role-change handler doesn't enforce the "at least one owner" rule, only the removal handler does. This means an organization can end up orphaned if the sole owner demotes themselves, which is a data integrity issue and not typical for org/team management systems [see analysis](https://github.com/better-auth/better-auth/issues/2774). Until this is fixed in the plugin, you can add a check at the application layer: before allowing a role change from "owner" to any other role, query the org's members and count the owners. If there's only one, block the change and prompt the user to add another owner first. This mirrors the removal logic and will prevent orphaned organizations. This is a known gap and worth watching for updates, as the codebase is modular and the fix is likely to land in the member update logic in the plugin's route handlers. <!-- 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/5cff8a4e-eb15-4c3c-a15a-d1c69ca1a765?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/5cff8a4e-eb15-4c3c-a15a-d1c69ca1a765?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/5cff8a4e-eb15-4c3c-a15a-d1c69ca1a765?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/5cff8a4e-eb15-4c3c-a15a-d1c69ca1a765?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/5cff8a4e-eb15-4c3c-a15a-d1c69ca1a765?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/5cff8a4e-eb15-4c3c-a15a-d1c69ca1a765?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/5cff8a4e-eb15-4c3c-a15a-d1c69ca1a765?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&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/3651)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#9674