[GH-ISSUE #6038] Enhacement: /organization/get-full-organization exposes full member list by default, regardless of role #10408

Open
opened 2026-04-13 06:31:28 -05:00 by GiteaMirror · 9 comments
Owner

Originally created by @xthezealot on GitHub (Nov 17, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/6038

Originally assigned to: @ping-maxwell on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Create a Better Auth project with the organization() plugin enabled (default config).

  2. Add two users to the same organization:

    • User A: owner or admin
    • User B: simple member
  3. Log in as User B (the simple member).

  4. Call the built-in endpoint:

    POST /organization/get-full-organization
    

    using the session token for User B.

  5. The server returns the entire organization object including the full list of members.

Current vs. Expected behavior

Current behavior

  • Any authenticated member of an organization (role "member") can successfully call /organization/get-full-organization.
  • The response contains all members of the organization, including email, name, and metadata.
  • There is no configuration option to restrict this route to admin-level users.
  • The docs do not warn that this endpoint returns the full roster or that it is accessible to all members.

This leads to accidental data exposure in many multi-tenant SaaS applications where “member” users are end-customers who must not see each other.

Expected behavior
One of the following:

Option A (preferred):

  • By default, only roles "owner" and "admin" should be allowed to read the full member list.
  • Simple "member" role should not receive org-wide user data unless explicitly enabled.

Option B:

  • Add a config option to define which roles can access this data, e.g.:

    organization({
      fullOrganizationAccessRoles: ["owner", "admin"], // default
    })
    

Option C:

  • At minimum, document clearly that:

    • /organization/get-full-organization exposes the entire member list
    • Any member can call it by default
    • Developers who need stricter privacy must implement a before hook to restrict/block the route, or customize the default API route handler

What version of Better Auth are you using?

1.3.34

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

Documentation, Backend, Client

Auth config (if applicable)

import { betterAuth } from "better-auth"
import { organization } from "better-auth/plugins"

export const auth = betterAuth({
  emailAndPassword: {  
    enabled: true
  },
  plugins: [
    organization(),
  ],
})

Additional context

  • Many SaaS platforms using Better Auth treat org “members” as end-customers, not collaborators.
    In those cases, exposing member lists to other members is a significant privacy and security concern.

  • This behavior is easy to miss:

    • The plugin exposes high-level helpers like getFullOrganization() without obvious warnings.
    • Developers may unknowingly expose the route to simple members through networking interceptors or automatic client bindings.

Suggestion
Provide stricter defaults or configuration flags, and update the documentation to highlight that this endpoint returns all members and should be restricted for privacy-sensitive applications.

This would prevent accidental data exposure and align the plugin with a wider range of SaaS architectures (B2B, B2C, multi-tenant, etc.).

Originally created by @xthezealot on GitHub (Nov 17, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/6038 Originally assigned to: @ping-maxwell on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Create a Better Auth project with the `organization()` plugin enabled (default config). 2. Add two users to the same organization: * User A: `owner` or `admin` * User B: simple `member` 3. Log in as User B (the simple member). 4. Call the built-in endpoint: ``` POST /organization/get-full-organization ``` using the session token for User B. 5. The server returns the *entire* organization object including the full list of members. ### Current vs. Expected behavior **Current behavior** * Any authenticated **member** of an organization (role `"member"`) can successfully call `/organization/get-full-organization`. * The response contains **all members of the organization**, including email, name, and metadata. * There is no configuration option to restrict this route to admin-level users. * The docs do not warn that this endpoint returns the full roster or that it is accessible to all members. This leads to accidental data exposure in many multi-tenant SaaS applications where “member” users are end-customers who must not see each other. **Expected behavior** One of the following: **Option A (preferred):** * By default, only roles `"owner"` and `"admin"` should be allowed to read the full member list. * Simple `"member"` role should not receive org-wide user data unless explicitly enabled. **Option B:** * Add a config option to define which roles can access this data, e.g.: ```ts organization({ fullOrganizationAccessRoles: ["owner", "admin"], // default }) ``` **Option C:** * At minimum, **document clearly** that: * `/organization/get-full-organization` exposes the entire member list * Any member can call it by default * Developers who need stricter privacy must implement a `before` hook to restrict/block the route, or customize the default API route handler ### What version of Better Auth are you using? 1.3.34 ### Which area(s) are affected? (Select all that apply) Documentation, Backend, Client ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth" import { organization } from "better-auth/plugins" export const auth = betterAuth({ emailAndPassword: { enabled: true }, plugins: [ organization(), ], }) ``` ### Additional context * Many SaaS platforms using Better Auth treat org “members” as **end-customers**, not collaborators. In those cases, exposing member lists to other members is a significant privacy and security concern. * This behavior is easy to miss: * The plugin exposes high-level helpers like `getFullOrganization()` without obvious warnings. * Developers may unknowingly expose the route to simple members through networking interceptors or automatic client bindings. **Suggestion** Provide stricter defaults or configuration flags, and update the documentation to highlight that this endpoint returns *all* members and should be restricted for privacy-sensitive applications. This would prevent accidental data exposure and align the plugin with a wider range of SaaS architectures (B2B, B2C, multi-tenant, etc.).
GiteaMirror added the securityorganization labels 2026-04-13 06:31:28 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Nov 17, 2025):

Thanks for raising this important concern about the /organization/get-full-organization endpoint. It’s clear that exposing the full organization member list—even for simple members—can lead to unintended data exposure in privacy-sensitive multi-tenant applications. Your proposed options, such as allowing only owner and admin roles by default or introducing a configuration flag to restrict access, are sensible approaches to mitigate this risk [1]. One immediate workaround is to add a custom “before” hook in your auth configuration to check the user role and block access for members until a stricter default is provided [1].

Have you already implemented a temporary solution or do you need further assistance on adding such a hook? Tag @better-auth-agent if you would like an answer from the Agent.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3541775876 --> @better-auth-agent[bot] commented on GitHub (Nov 17, 2025): Thanks for raising this important concern about the /organization/get-full-organization endpoint. It’s clear that exposing the full organization member list—even for simple members—can lead to unintended data exposure in privacy-sensitive multi-tenant applications. Your proposed options, such as allowing only owner and admin roles by default or introducing a configuration flag to restrict access, are sensible approaches to mitigate this risk [[1]](https://www.better-auth.com/). One immediate workaround is to add a custom “before” hook in your auth configuration to check the user role and block access for members until a stricter default is provided [[1]](https://www.better-auth.com/). Have you already implemented a temporary solution or do you need further assistance on adding such a hook? Tag @better-auth-agent if you would like an answer from the Agent. <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) • [Discord](https://discord.gg/better-auth) • [GitHub](https://github.com/better-auth/better-auth) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/better-auth) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/better-auth/better-auth)
Author
Owner

@xthezealot commented on GitHub (Nov 17, 2025):

Here is my temporary solution for Next.js, implemented in app/api/auth/[...all]/route.ts:

import { toNextJsHandler } from "better-auth/next-js"
import { NextResponse } from "next/server"
import auth from "@/lib/auth"

const handler = toNextJsHandler(auth)

// restrict member enumeration to non-managers (roles other than owner/admin)
export async function GET(request: Request) {
  const session = await auth.api.getSession({ headers: request.headers })
  const response = await handler.GET(request)

  // check if user has an active organization
  if (!session?.session.activeOrganizationId) {
    return response
  }

  // check if user is a manager (owner or admin)
  const { role } = await auth.api.getActiveMemberRole({ headers: request.headers })
  const roles = role.split(",")
  if (roles.includes("owner") || roles.includes("admin")) {
    return response
  }

  // check if the response body is a json object
  const json = await response.clone().json()
  if (!(typeof json === "object" && !Array.isArray(json))) {
    return response
  }

  // filter members to only include the current user
  const sessionUserId = session?.session.userId
  const members = Array.isArray(json.members)
    ? json.members.filter((member: { userId?: string }) => member?.userId === sessionUserId)
    : json.members

  // clean up headers
  const headers = new Headers(response.headers)
  headers.delete("content-length")

  // respond with filtered data
  return NextResponse.json({ ...json, members, invitations: undefined }, { status: response.status, headers })
}

export function POST(request: Request) {
  return handler.POST(request)
}
<!-- gh-comment-id:3541805633 --> @xthezealot commented on GitHub (Nov 17, 2025): Here is my temporary solution for Next.js, implemented in `app/api/auth/[...all]/route.ts`: ```ts import { toNextJsHandler } from "better-auth/next-js" import { NextResponse } from "next/server" import auth from "@/lib/auth" const handler = toNextJsHandler(auth) // restrict member enumeration to non-managers (roles other than owner/admin) export async function GET(request: Request) { const session = await auth.api.getSession({ headers: request.headers }) const response = await handler.GET(request) // check if user has an active organization if (!session?.session.activeOrganizationId) { return response } // check if user is a manager (owner or admin) const { role } = await auth.api.getActiveMemberRole({ headers: request.headers }) const roles = role.split(",") if (roles.includes("owner") || roles.includes("admin")) { return response } // check if the response body is a json object const json = await response.clone().json() if (!(typeof json === "object" && !Array.isArray(json))) { return response } // filter members to only include the current user const sessionUserId = session?.session.userId const members = Array.isArray(json.members) ? json.members.filter((member: { userId?: string }) => member?.userId === sessionUserId) : json.members // clean up headers const headers = new Headers(response.headers) headers.delete("content-length") // respond with filtered data return NextResponse.json({ ...json, members, invitations: undefined }, { status: response.status, headers }) } export function POST(request: Request) { return handler.POST(request) } ```
Author
Owner

@pranavgoel29 commented on GitHub (Jan 24, 2026):

ANY UPDATES ON THIS?

<!-- gh-comment-id:3793066038 --> @pranavgoel29 commented on GitHub (Jan 24, 2026): ANY UPDATES ON THIS?
Author
Owner

@1jmj commented on GitHub (Feb 5, 2026):

I see the PR didn't make it through. Is anyone working on this? IMO it's like a breach.

<!-- gh-comment-id:3852601019 --> @1jmj commented on GitHub (Feb 5, 2026): I see the PR didn't make it through. Is anyone working on this? IMO it's like a breach.
Author
Owner

@andrasf commented on GitHub (Feb 18, 2026):

Enhancement? This is a serious security problem in the plugin that can easily go unnoticed.

<!-- gh-comment-id:3921097808 --> @andrasf commented on GitHub (Feb 18, 2026): Enhancement? This is a serious security problem in the plugin that can easily go unnoticed.
Author
Owner

@1jmj commented on GitHub (Feb 19, 2026):

My mitigation:

   hooks: {
      after: createAuthMiddleware(async (ctx) => {
        if (ctx.path === '/organization/get-full-organization') {
          const returned = ctx.context.returned as
            | {
                members: {
                  role: 'member' | 'owner' | 'manager';
                  user: {
                    id: string;
                  };
                }[];
                invitations: {
                }[];
              }
            | null
            | undefined;

          if (!returned || typeof returned !== 'object') {
            return;
          }
          const members = Array.isArray(returned.members)
            ? returned.members
            : [];

          const authSession = await getSessionFromCtx(ctx);
          const currentUserId = authSession?.user?.id;
          if (!currentUserId) {
            return;
          }

          const myRole = members.find((m) => m.userId === currentUserId)?.role;
          const canViewRoster = myRole === 'owner' || myRole === 'manager';
          if (!canViewRoster) {
            ctx.context.returned = {
              ...returned,
              members: undefined,
              invitations: undefined,
            };
          }
        }
<!-- gh-comment-id:3926476318 --> @1jmj commented on GitHub (Feb 19, 2026): My mitigation: ``` hooks: { after: createAuthMiddleware(async (ctx) => { if (ctx.path === '/organization/get-full-organization') { const returned = ctx.context.returned as | { members: { role: 'member' | 'owner' | 'manager'; user: { id: string; }; }[]; invitations: { }[]; } | null | undefined; if (!returned || typeof returned !== 'object') { return; } const members = Array.isArray(returned.members) ? returned.members : []; const authSession = await getSessionFromCtx(ctx); const currentUserId = authSession?.user?.id; if (!currentUserId) { return; } const myRole = members.find((m) => m.userId === currentUserId)?.role; const canViewRoster = myRole === 'owner' || myRole === 'manager'; if (!canViewRoster) { ctx.context.returned = { ...returned, members: undefined, invitations: undefined, }; } } ```
Author
Owner

@ping-maxwell commented on GitHub (Feb 21, 2026):

Hello, I'll be including this in the organization rewrite:
https://github.com/better-auth/better-auth/pull/7886

<!-- gh-comment-id:3939593414 --> @ping-maxwell commented on GitHub (Feb 21, 2026): Hello, I'll be including this in the organization rewrite: \ <https://github.com/better-auth/better-auth/pull/7886>
Author
Owner

@anton89 commented on GitHub (Mar 29, 2026):

This is only for 2.0? so for 1.xx we should just use the workaround right

<!-- gh-comment-id:4149391451 --> @anton89 commented on GitHub (Mar 29, 2026): This is only for 2.0? so for 1.xx we should just use the workaround right
Author
Owner

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

This is only for 2.0? so for 1.xx we should just use the workaround right

yeah

<!-- gh-comment-id:4149822068 --> @ping-maxwell commented on GitHub (Mar 29, 2026): > This is only for 2.0? so for 1.xx we should just use the workaround right yeah
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#10408