Skip to main content
Back to Blog
By Mahmut Jomaa6 min read

How to Prevent Ownerless Organizations in a Next.js SaaS

Protect multi-tenant organizations when users leave or delete their accounts with server-side ownership checks for Better Auth, Prisma and Drizzle.

Organization ownership paths connecting users to an isolated SaaS workspace
On this page11 sections

Deleting a user account looks like a user-scoped operation. In a multi-tenant SaaS application, it can also change the authorization structure of every organization that user owns.

If membership rows cascade when a user is deleted, a sole owner can disappear while the organization remains. The result may be an empty organization or, worse, an organization with active members but nobody authorized to administer it.

That makes account deletion an organization integrity boundary. The application must check ownership on the server before the authentication system removes the user.

This guide explains the approach used by the Achromatic Pro Prisma and Drizzle starter kits with Better Auth.

The orphaned organization problem

Consider an organization with three members:

UserRole
AlexOwner
SamMember
TaylorMember

If Alex deletes their account and the membership relation uses ON DELETE CASCADE, Alex's membership disappears automatically. Sam and Taylor can still belong to the organization, but neither can perform owner-only actions such as transferring ownership, changing sensitive settings or deleting the workspace.

The database has done exactly what its foreign keys requested. The application has still allowed an invalid business state.

The same loophole appears when the product already blocks a sole owner from leaving an organization but account deletion bypasses that workflow. Every path that removes the final owner needs the same invariant.

Define the invariant precisely

A user should be blocked from deleting their account when at least one organization satisfies both conditions:

  1. The user has an owner membership in the organization.
  2. No different user has an owner membership in that organization.

The number of ordinary members does not change the answer. An organization with one owner and ten members still has a sole owner.

Likewise, account deletion should remain available when:

  • the user belongs only as a member
  • every organization they own has another owner
  • the user does not belong to an organization

Keeping the rule this narrow avoids turning a safety check into unnecessary account lock-in.

Enforce it at the authentication boundary

A disabled button is useful guidance, but it is not authorization. A user can call the deletion endpoint directly, use another client or submit a request from an older browser tab.

Better Auth exposes a beforeDelete hook for the server-side decision:

lib/auth/index.ts
import { betterAuth } from 'better-auth';

import { assertAccountDeletionAllowedForUser } from '@/lib/auth/account-deletion';

export const auth = betterAuth({
  user: {
    deleteUser: {
      enabled: true,
      beforeDelete: async (user) => {
        await assertAccountDeletionAllowedForUser(user.id);
      }
    }
  }
});

The hook runs immediately before Better Auth deletes the user. If the guard throws, deletion stops before cascade behavior can remove memberships or other dependent records.

Keep this check close to the destructive mutation. A page loader or React component can improve the experience, but neither can protect API calls made outside that render cycle.

Query sole ownership with Prisma

With Prisma, express the two halves of the invariant through relation filters:

lib/auth/account-deletion.ts
import { MemberRole } from '@prisma/client';

import { prisma } from '@/lib/db';

export async function findSoleOwnedOrganizations(userId: string) {
  return prisma.organization.findMany({
    where: {
      AND: [
        {
          members: {
            some: { userId, role: MemberRole.owner }
          }
        },
        {
          members: {
            none: {
              userId: { not: userId },
              role: MemberRole.owner
            }
          }
        }
      ]
    },
    select: { id: true, name: true }
  });
}

The some clause proves that the deleting user owns the organization. The none clause proves that a different owner does not exist.

Select only the fields needed by the guard. This keeps the query small and also makes it possible to add organization names to future guidance without exposing the full membership graph.

Query sole ownership with Drizzle

The equivalent Drizzle query can use a correlated NOT EXISTS subquery. Alias the membership table so the database can distinguish the current user's owner row from a possible second owner:

lib/auth/account-deletion.ts
import { and, eq, ne, notExists } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';

import { db } from '@/lib/db';
import { MemberRole } from '@/lib/db/schema/enums';
import { memberTable, organizationTable } from '@/lib/db/schema/tables';

export async function findSoleOwnedOrganizations(userId: string) {
  const otherOwner = alias(memberTable, 'other_owner');

  return db
    .select({
      id: organizationTable.id,
      name: organizationTable.name
    })
    .from(memberTable)
    .innerJoin(
      organizationTable,
      eq(organizationTable.id, memberTable.organizationId)
    )
    .where(
      and(
        eq(memberTable.userId, userId),
        eq(memberTable.role, MemberRole.owner),
        notExists(
          db
            .select({ id: otherOwner.id })
            .from(otherOwner)
            .where(
              and(
                eq(otherOwner.organizationId, memberTable.organizationId),
                eq(otherOwner.role, MemberRole.owner),
                ne(otherOwner.userId, userId)
              )
            )
        )
      )
    );
}

NOT EXISTS maps directly to the business rule: no other owner may exist for the same organization. It also lets PostgreSQL stop searching as soon as it finds a qualifying row.

Return a stable application error

Do not throw a generic database error and make the browser guess what happened. Turn the failed invariant into an explicit API response:

lib/auth/account-deletion.ts
import { APIError } from 'better-auth/api';

export const ACCOUNT_DELETION_BLOCKED_CODE =
  'ACCOUNT_DELETION_BLOCKED_BY_ORGANIZATION_OWNERSHIP';

export const ACCOUNT_DELETION_BLOCKED_MESSAGE =
  'Transfer ownership or delete the organizations you solely own before deleting your account.';

export async function assertAccountDeletionAllowedForUser(userId: string) {
  const organizations = await findSoleOwnedOrganizations(userId);

  if (organizations.length > 0) {
    throw new APIError('FORBIDDEN', {
      code: ACCOUNT_DELETION_BLOCKED_CODE,
      message: ACCOUNT_DELETION_BLOCKED_MESSAGE
    });
  }
}

A stable code is useful for clients, logs and tests. The message should explain the remedy instead of merely saying that deletion is forbidden.

Avoid returning organization details unless the authenticated user is allowed to see them. The guard needs names only if the UI deliberately lists the workspaces that require action.

Make the confirmation honest

The confirmation modal should prepare the user before they submit:

Are you sure you want to delete your account? You must transfer ownership or delete any organization you solely own first.

Then handle the structured server error and keep the modal open. Closing it before the mutation succeeds makes a recoverable ownership problem feel like a broken action.

Good destructive-action behavior includes:

  • clear irreversible-action copy
  • an explicit destructive button label
  • a pending state that prevents duplicate submissions
  • the server's actionable ownership message
  • no optimistic removal of the account
  • no duplicate generic toast layered over the specific error

The interface communicates the rule. The server enforces it.

Test the ownership matrix

The most important tests cover role combinations rather than component markup.

At minimum, verify that deletion is:

  • blocked for an organization's only owner
  • blocked when other members exist but none is an owner
  • blocked if any one of several organizations is solely owned
  • allowed when every owned organization has another owner
  • allowed for an ordinary member
  • allowed for a user without memberships

Also test that the Better Auth hook invokes the guard and propagates the structured FORBIDDEN response.

ORM mocks are helpful for testing the assertion behavior, but run the ownership query against PostgreSQL too. A real database test catches aliasing, enum and relation-filter mistakes that a mocked return value cannot reveal.

Finally, smoke test the account settings flow in a browser with both a sole owner and a co-owner account. Confirm the displayed message, pending state and successful deletion path.

Consider concurrency separately

The preflight closes the direct account-deletion bypass, but highly concurrent ownership changes can require stronger guarantees. For example, two co-owners could attempt to remove themselves at nearly the same time.

Products with that risk should serialize final-owner mutations in a transaction or enforce the invariant through a database strategy appropriate to their write model. A simple row count followed by a separate delete is not automatically a global concurrency guarantee.

This is also why ownership transfer should be a deliberate server operation, not two unrelated client calls that demote one owner and promote another.

Apply the invariant to every exit path

Account deletion is only one way an owner can disappear. Review every operation that can affect the final owner:

  • leaving an organization
  • removing a member
  • changing an owner's role
  • deleting or anonymizing a user through an admin panel
  • identity-provider deprovisioning
  • automated retention workflows

Each path should either preserve another owner, transfer ownership atomically or delete the organization intentionally.

The exact policy can differ by product. The invariant should not.

What ships in Achromatic

Achromatic Pro Prisma and Pro Drizzle now enforce the ownership check inside Better Auth before account deletion. Both implementations include ORM-specific queries, structured API errors, actionable confirmation copy and focused tests.

The guard complements the existing protection that prevents a sole owner from leaving an organization. Together, they remove two common paths to ownerless tenant data while preserving account deletion for members and co-owners.

See the complete release in the Achromatic changelog or compare the available Next.js SaaS starter kits.