[GH-ISSUE #3233] Issue: activeOrganizationId property lost when using customSession with organization plugin #9532

Closed
opened 2026-04-13 05:01:44 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @MikeCodeur on GitHub (Jul 1, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/3233

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Problem Description

When using Better Auth's customSession plugin in combination with the organization plugin, the activeOrganizationId property becomes unavailable in the session object. This issue occurs due to TypeScript type inference conflicts and plugin interaction order.

The activeOrganizationId property is crucial for multi-tenant applications where users can belong to multiple organizations and need to switch between them during their session.

Environment

  • Better Auth version: 1.2.12
  • TypeScript version: 5.0+
  • Plugins used: customSession, organization

Steps to Reproduce

1. Configure Better Auth with both plugins

import { betterAuth } from 'better-auth'
import { customSession, organization } from 'better-auth/plugins'

export const auth = betterAuth({
  plugins: [
    customSession(async ({user, session}) => {
      const enrichedUser = await getUserData(session.userId)
      return {
        user: enrichedUser,
        session
      }
    }),
    organization({
      async sendInvitationEmail(data) {
        // invitation logic
      },
    }),
  ],
})

2. Try to access activeOrganizationId

const session = await auth.api.getSession()
console.log(session.session.activeOrganizationId)

3. Check TypeScript compilation

// This will show TypeScript error:
// Property 'activeOrganizationId' does not exist on type 'Session'
const orgId: string = session.session.activeOrganizationId

Root Cause Analysis

Plugin Order Dependencies

Better Auth plugins are processed sequentially, and the order affects type inference:

  1. Type Extension Chain: Each plugin extends the base session/user types
  2. Interference Pattern: customSession placed before organization prevents proper type extension
  3. Property Masking: Custom session transformations don't automatically preserve properties from subsequent plugins

TypeScript Type Inference

The customSession plugin modifies the return type of session objects. When placed before the organization plugin, TypeScript cannot infer that activeOrganizationId will be added to the session type.

// What happens internally:
// 1. customSession transforms session type
// 2. organization plugin tries to extend already-transformed type  
// 3. Type intersection fails
// 4. activeOrganizationId property is lost

References

Current vs. Expected behavior

Expected Behavior

TypeScript should recognize the activeOrganizationId field and it should be available at runtime:

const session = await auth.api.getSession()
const orgId = session.session.activeOrganizationId // TypeScript OK, returns string | undefined

await auth.api.setActiveOrganization({
  headers,
  body: { organizationId: 'org-123' }
})

What version of Better Auth are you using?

1.2.12

Provide environment information

- WSL2
- Node 22.14

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

Backend

Auth config (if applicable)

import { betterAuth } from 'better-auth'
import { customSession, organization } from 'better-auth/plugins'

export const auth = betterAuth({
  plugins: [
    customSession(async ({user, session}) => {
      const enrichedUser = await getUserData(session.userId)
      return {
        user: enrichedUser,
        session
      }
    }),
    organization({
      async sendInvitationEmail(data) {
        // invitation logic
      },
    }),
  ],
})

Additional context

No response

Originally created by @MikeCodeur on GitHub (Jul 1, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/3233 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce ## Problem Description When using Better Auth's `customSession` plugin in combination with the `organization` plugin, the `activeOrganizationId` property becomes unavailable in the session object. This issue occurs due to TypeScript type inference conflicts and plugin interaction order. The `activeOrganizationId` property is crucial for multi-tenant applications where users can belong to multiple organizations and need to switch between them during their session. ## Environment - **Better Auth version**: 1.2.12 - **TypeScript version**: 5.0+ - **Plugins used**: `customSession`, `organization` ## Steps to Reproduce ### 1. Configure Better Auth with both plugins ```typescript import { betterAuth } from 'better-auth' import { customSession, organization } from 'better-auth/plugins' export const auth = betterAuth({ plugins: [ customSession(async ({user, session}) => { const enrichedUser = await getUserData(session.userId) return { user: enrichedUser, session } }), organization({ async sendInvitationEmail(data) { // invitation logic }, }), ], }) ``` ### 2. Try to access activeOrganizationId ```typescript const session = await auth.api.getSession() console.log(session.session.activeOrganizationId) ``` ### 3. Check TypeScript compilation ```typescript // This will show TypeScript error: // Property 'activeOrganizationId' does not exist on type 'Session' const orgId: string = session.session.activeOrganizationId ``` ## Root Cause Analysis ### Plugin Order Dependencies Better Auth plugins are processed sequentially, and the order affects type inference: 1. **Type Extension Chain**: Each plugin extends the base session/user types 2. **Interference Pattern**: `customSession` placed before `organization` prevents proper type extension 3. **Property Masking**: Custom session transformations don't automatically preserve properties from subsequent plugins ### TypeScript Type Inference The `customSession` plugin modifies the return type of session objects. When placed before the `organization` plugin, TypeScript cannot infer that `activeOrganizationId` will be added to the session type. ```typescript // What happens internally: // 1. customSession transforms session type // 2. organization plugin tries to extend already-transformed type // 3. Type intersection fails // 4. activeOrganizationId property is lost ``` ## References - [Better Auth customSession documentation](https://www.better-auth.com/docs/concepts/session-management#customizing-session-response) - [Better Auth organization plugin documentation](https://www.better-auth.com/docs/plugins/organization) ### Current vs. Expected behavior ## Expected Behavior TypeScript should recognize the `activeOrganizationId` field and it should be available at runtime: ```typescript const session = await auth.api.getSession() const orgId = session.session.activeOrganizationId // TypeScript OK, returns string | undefined await auth.api.setActiveOrganization({ headers, body: { organizationId: 'org-123' } }) ``` ### What version of Better Auth are you using? 1.2.12 ### Provide environment information ```bash - WSL2 - Node 22.14 ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from 'better-auth' import { customSession, organization } from 'better-auth/plugins' export const auth = betterAuth({ plugins: [ customSession(async ({user, session}) => { const enrichedUser = await getUserData(session.userId) return { user: enrichedUser, session } }), organization({ async sendInvitationEmail(data) { // invitation logic }, }), ], }) ``` ### Additional context _No response_
GiteaMirror added the locked label 2026-04-13 05:01:44 -05:00
Author
Owner

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

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

This is a known limitation in Better Auth's plugin/type system: when using customSession with organization, TypeScript may lose properties like activeOrganizationId due to how plugin order and type inference work. The documentation explicitly notes that the session object passed to customSession does not infer fields added by other plugins, and recommends a workaround: define your Better Auth options separately (using satisfies BetterAuthOptions), then pass those options to customSession so TypeScript can infer all plugin-added fields, including from organization. This ensures activeOrganizationId is available in your session type at compile time. See the official session management docs for an example.

There are several related issues about plugin type inference and missing properties when combining plugins (like #2789, #3067), confirming this is a common pain point. There are no direct code examples or tests in the repo showing customSession and organization together, but the workaround above is the recommended approach.

This isn't considered a bug, but rather a limitation of the current plugin/type system and TypeScript's inference model in Better Auth. If you follow the pattern from the docs, TypeScript should recognize activeOrganizationId as expected.

To reply, just mention @dosu.


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

<!-- gh-comment-id:3021659951 --> @dosubot[bot] commented on GitHub (Jul 1, 2025): <!-- Greeting --> Hi @MikeCodeur! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is a known limitation in Better Auth's plugin/type system: when using customSession with organization, TypeScript may lose properties like activeOrganizationId due to how plugin order and type inference work. The documentation explicitly notes that the session object passed to customSession does not infer fields added by other plugins, and recommends a workaround: define your Better Auth options separately (using satisfies BetterAuthOptions), then pass those options to customSession so TypeScript can infer all plugin-added fields, including from organization. This ensures activeOrganizationId is available in your session type at compile time. See the [official session management docs for an example](https://github.com/better-auth/better-auth/blob/12e15cce41fceb36ddefcfc39fc66e082b8af699/docs/content/docs/concepts/session-management.mdx). There are several related issues about plugin type inference and missing properties when combining plugins (like [#2789](https://github.com/better-auth/better-auth/issues/2789), [#3067](https://github.com/better-auth/better-auth/issues/3067)), confirming this is a common pain point. There are no direct code examples or tests in the repo showing customSession and organization together, but the workaround above is the recommended approach. This isn't considered a bug, but rather a limitation of the current plugin/type system and TypeScript's inference model in Better Auth. If you follow the pattern from the docs, TypeScript should recognize activeOrganizationId as expected. <!-- 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/61dfe97e-ff5e-402f-ae27-2d6a15b67586?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/61dfe97e-ff5e-402f-ae27-2d6a15b67586?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/61dfe97e-ff5e-402f-ae27-2d6a15b67586?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/61dfe97e-ff5e-402f-ae27-2d6a15b67586?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/61dfe97e-ff5e-402f-ae27-2d6a15b67586?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/61dfe97e-ff5e-402f-ae27-2d6a15b67586?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/61dfe97e-ff5e-402f-ae27-2d6a15b67586?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/3233)
Author
Owner

@Qodestackr commented on GitHub (Jul 2, 2025):

Mine works for some reason but its duck tapes 😆 ... What does not work is my logic that checks if its a signup or sign-in so I dont recreate Saleor resources like warehouseIds and channels...

import { betterAuth, logger, type BetterAuthOptions } from "better-auth";
import {
  bearer,
  admin as adminPlugin,
  multiSession,
  organization,
  twoFactor,
  oAuthProxy,
  openAPI,
  oidcProvider,
  customSession,
  phoneNumber,
  createAuthMiddleware,
} from "better-auth/plugins";
import { prismaAdapter } from "better-auth/adapters/prisma";

import { mapUserRoleToBusiness } from "./role-management";
import { APP_BASE_URL } from "@workspace/utils";
// import { reactInvitationEmail } from "@/components/emails/invitation";
import { ac, roles } from "./permissions";
// import { reactResetPasswordEmail } from "@/components/emails/reset-password-email";
// import { resend } from "@workspace/notifications";
import { nextCookies } from "better-auth/next-js";
// import { initializeOrganizationPermissions } from "@/services/permission-service";
import { generateId } from "@workspace/utils"
import { setupSaleorResourcesForNewUser } from "@workspace/coremmerce/server-only";
import db, { PaymentMethodType } from "@workspace/db";
import { atSMS } from "./africastalking";

const ALLOWED_ROLES = [
  "retailer",
  "wholesaler",
  "distributor",
  "user",
  "driver",
  "brand_owner",
] as const;

const businessRoles = new Set([
  "distributor",
  "retailer",
  "wholesaler",
  "brand_owner",
]);

const AUTH_CONFIG = {
  from: "online@**.com",
  to: process.env.TEST_EMAIL || "",
  trustedOrigins: [
    "http://46.202.130.243:3000",
    "http://46.202.130.243:7700",
    "http://46.202.130.243:8000",
    "https://commerce.**.com",
    "https://search.**.com",
    "https://**.com",
    "alcorabooks.com",
    "https://www.**.com",
  ],
  appName: "Alcorabooks",
  allowedRoles: ALLOWED_ROLES,
};

export const auth: any = betterAuth({
  trustedOrigins: AUTH_CONFIG.trustedOrigins,
  appName: AUTH_CONFIG.appName,

  secret: "....",

  database: prismaAdapter(db, {
    provider: "postgresql",
  }),
  logger: {
    level: "debug",
    disabled: false
  },

  hooks: {
    after: createAuthMiddleware(async (ctx) => {
      // Handle newly authenticated users
      if (ctx.path.startsWith("/sign-up") || ctx.path.startsWith("/sign-in")) {
        const newSession = ctx.context.newSession;
        if (newSession) {
          ctx.setCookie(
            "Set-Cookie",
            `session_token=${ctx.context.authCookies.sessionToken}; Path=/; HttpOnly; SameSite=Strict`
          );
          // Check if user has pending onboarding
          // const org = await getOrganizationByUserId(newSession.user.id);
          const org = {} as any;
          if (org && org.metadata?.pendingOnboarding) {
            // Store onboarding state in session....
          }
        }
      }
    }),
  },

  databaseHooks: {
    user: {
      create: {
        before: async (user, ctx) => {
          const role = ctx?.body?.additionalFields?.role ?? "user";
          if (!ALLOWED_ROLES.includes(role)) {
            throw new Error("Invalid role assignment");
          }
          return { data: { ...user, role } };
        },
        after: async (user, ctx) => {
        }
      },
    },
    session: {
      create: {
        before: async (session, ctx) => {
          const membership = await db.member.findFirst({
            where: { userId: session.userId },
            include: { organization: true },
            orderBy: { createdAt: "asc" }, // prioritize oldest org if multiple
          });

          if (!membership) {
            console.warn("No organization found for user:", session.userId);
            return { data: session }; // return session as-is, no org
          }

          return {
            data: {
              ...session,
              activeOrganizationId: membership.organization.id,
            },
          };
        },
        after: async (session, ctx) => {
          console.log("Session after create:", session,);

          try {
            // 0) Bail early if this is just a sign‑in, not a sign‑up
            const isSignUp =  Boolean(ctx?.context.newSession) &&  (ctx?.path.startsWith("/sign-up") || ctx?.path.startsWith("/auth/phone-number/verify"));
            if (!isSignUp) {
              logger.info("Sign‑in detected — skipping org & Saleor provisioning");
              return;
            }

            // 1) Fetch user & role
            const user = await db.user.findUnique({ where: { id: session.userId } });
            if (!user) return;

            // 2) Only business roles get orgs
            if (!businessRoles.has(user.role)) {
              logger.info(`Role=${user.role} — skipping org creation for B2C user`);
              return;
            }

            // 3) Check for existing org membership
            let membership = await db.member.findFirst({
              where: { userId: user.id },
              include: { organization: true },
            });

            // 4) True sign‑up + business role + no membership → create org + provision
            if (!membership) {
              const org = await db.organization.create({
                data: {
                  name: `${user.name}'s Organization`,
                  slug: generateId(),
                  businessType: mapUserRoleToBusiness(user.role),
                  paymentMethod: PaymentMethodType.MPESA,
                  members: { create: { userId: user.id, role: "OWNER" } },
                },
              });

              // Update session
              await db.session.update({
                where: { id: session.id },
                data: { activeOrganizationId: org.id },
              });

              (session as any).activeOrganizationId = org.id;

              // Provision Saleor resources
              await setupSaleorResourcesForNewUser(
                user.id,
                org.id,
                session.id
              );

              logger.info("New business sign‑up: org & Saleor provisioned");
            } else {
              logger.info("Existing business user sign‑up? Membership found — skipping provisioning");
            }
          } catch (error) {
            console.error("Error in session.create.after:", error);
          }
        },

      },
    },
  },
  user: {
    additionalFields: {
      role: {
        type: "string",
        required: true,
        defaultValue: "user",
        input: true,
      },
      premium: {
        type: "boolean",
        required: false,
        defaultValue: false,
        input: false,
      },
      // Phone number for SMS notifications not from better-auth auth plugins
      // @ref https://www.better-auth.com/docs/plugins/phone-number
      phoneNumber: {
        type: "string",
        required: false,
        input: true,
      },
      enableSMS: {
        type: "boolean",
        required: false,
        defaultValue: true,
        input: true,
      },
    },
    changeEmail: {
      enabled: true,
      sendChangeEmailVerification: async (
        { user, newEmail, url, token },
        request
      ) => {
        console.log("TODO: CHANGE EMAIL");
      },
    },
  },

  session: {
    cookieCache: {
      enabled: true,
      maxAge: 60 * 60 * 24 * 7,
    },
    expiresIn: 60 * 60 * 24 * 7,
    updateAge: 60 * 60 * 24, // (every 1 day the session expiration is updated)
    additionalFields: {
      saleorChannelId: {
        type: "string",
        required: false,
      },
      saleorChannelSlug: {
        type: "string",
        required: false,
      },
      organizationId: {
        type: "string",
        required: false,
      },
      organizationName: {
        type: "string",
        required: false,
      },
      organizationSlug: {
        type: "string",
        required: false,
      },
      businessType: {
        type: "string",
        required: false,
      },
      warehouseId: {
        type: "string",
        required: false,
      },
    },
  },

  emailVerification: {
    sendOnSignUp: true,
    autoSignInAfterVerification: true,
    async sendVerificationEmail({ user, url }) {
      // const verificationUrl = `${process.env.BETTER_AUTH_URL}/api/auth/verify-email?token=${token}&callbackURL=${process.env.EMAIL_VERIFICATION_CALLBACK_URL}`;

      // const res = await resend.emails.send({
      //   from: AUTH_CONFIG.from,
      //   to: AUTH_CONFIG.to || user.email,
      //   subject: "Verify your email address",
      //   html: `<a href="${url}">Verify your email address</a>`,
      // });
      console.log(user.email);
    },
  },
  // account: {
  //   accountLinking: {
  //     trustedProviders: ["google", "microsoft"],
  //   },
  // },
  emailAndPassword: {
    enabled: true,
    // requireEmailVerification: true,
    // async sendResetPassword({ user, url }) {
    //   await resend.emails.send({
    //     from: AUTH_CONFIG.from,
    //     to: user.email,
    //     subject: "Reset your password",
    //     react: reactResetPasswordEmail({
    //       username: user.email,
    //       resetLink: url,
    //     }),
    //   });
    // },
  },
  socialProviders: {
    facebook: {
      clientId: process.env.FACEBOOK_CLIENT_ID || "",
      clientSecret: process.env.FACEBOOK_CLIENT_SECRET || "",
    },
    google: {
      clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "",
      clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
    },
    microsoft: {
      clientId: process.env.MICROSOFT_CLIENT_ID || "",
      clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "",
    },
  },
  plugins: [
    oidcProvider({
      loginPage: "/sign-in",
      requirePKCE: true,
      consentPage: "/oauth/consent",
      metadata: {
        issuer: APP_BASE_URL,
        authorization_endpoint: `${APP_BASE_URL}/oauth/authorize`,
        token_endpoint: `${APP_BASE_URL}/oauth/token`,
        userinfo_endpoint: `${APP_BASE_URL}/userinfo`,
        jwks_uri: `${APP_BASE_URL}/.well-known/jwks.json`,
        response_types_supported: ["code"],
        subject_types_supported: ["public"],
        id_token_signing_alg_values_supported: ["RS256"],
      },
      // Allow Saleor as a client
      allowDynamicClientRegistration: true,
    }),
    organization({
      allowUserToCreateOrganization: async (user) => {
        return businessRoles.has((user as any).role);
      },
      membershipLimit: 200_0,
      organizationLimit: 10,
      ac: ac,
      roles: {
        owner: roles.owner,
        admin: roles.admin,
        wholesaler: roles.wholesaler,
        retailer: roles.retailer,
        bartender: roles.bartender,
        cashier: roles.cashier,
        finance: roles.finance,
        driver: roles.driver,
      },
      // Custom schema configuration for Organization
      schema: {
        organization: {
          fields: {
            // Define how organization.metadata maps to our custom fields
            metadata: "metadata",
          },
          // Additional fields/columns not automatically handled by the plugin
          additionalFields: {
            businessType: {
              type: "string",
              required: false,
              defaultValue: "",
            },
            description: {
              type: "string",
              required: false,
            },
            logo: {
              type: "string",
              required: false,
            },
            city: {
              type: "string",
              required: false,
            },
            address: {
              type: "string",
              required: false,
            },
            phoneNumber: {
              type: "string",
              required: false,
            },
            enableSMS: {
              type: "boolean",
              required: false,
              defaultValue: true,
            },
            paymentMethod: {
              type: "string",
              required: false,
            },
            subscriptionPlan: {
              type: "string",
              required: false,
            },
            onboardingComplete: {
              type: "boolean",
              required: false,
              defaultValue: false,
            },
            channel: {
              type: "string",
              required: false,
              defaultValue: true,
            },
          },
        },
      },
      async sendInvitationEmail(data) {
        // await resend.emails.send({
        //   from: AUTH_CONFIG.from,
        //   to: data.email,
        //   subject: "You've been invited to join an organization",
        //   react: reactInvitationEmail({
        //     username: data.email,
        //     invitedByUsername: data.inviter.user.name,
        //     invitedByEmail: data.inviter.user.email,
        //     teamName: data.organization.name,
        //     inviteLink:
        //       process.env.NODE_ENV === "development"
        //         ? `http://localhost:3000/accept-invitation/${data.id}`
        //         : `${process.env.BETTER_AUTH_URL || APP_BASE_URL
        //         }/accept-invitation/${data.id}`,
        //   }),
        // });
      },
      organizationCreation: {
        beforeCreate: async ({ organization, user }) => {
          const extendedOrg = {
            ...organization,
            slug: organization.slug || generateId(),
            paymentMethod: PaymentMethodType.MPESA,
          }
          console.log("ext-org.user", extendedOrg, user, user.id)
          return {
            data: extendedOrg,
          };
        },
        afterCreate: async ({ organization, user }) => {
          // TODO: Attach Saleor resources
          console.log(organization, "afterCreate")
        },
      },
    }),
    // customSession(async ({ user, session }) => {
    //   // First, get the organization from the active organization ID
    //   const org = await db.organization.findUnique({
    //     where: { id: (session as any)?.activeOrganizationId || "" },
    //     include: {
    //       saleorChannels: { where: { isActive: true }, take: 1 },
    //       warehouses: { take: 1 },
    //     },
    //   });

    //   // Get the default channel
    //   const defaultChannel = org?.saleorChannels?.[0];
    //   const defaultWarehouse = org?.warehouses?.[0];

    //   const transformedSession: any = { ...session };

    //   transformedSession.user = user;
    //   transformedSession.role = (user as any)?.role;
    //   transformedSession.organizationId = org?.id;
    //   transformedSession.organizationName = org?.name;
    //   transformedSession.organizationSlug = org?.slug;
    //   transformedSession.businessType = org?.businessType;

    //   // Adding chann properties last to ensure they aren't overridden
    //   if (defaultChannel) {
    //     transformedSession.saleorChannelId = defaultChannel.saleorChannelId;
    //     transformedSession.saleorChannelSlug = defaultChannel.slug;
    //   }

    //   if (defaultWarehouse) {
    //     transformedSession.warehouseId = defaultWarehouse.saleorWarehouseId;
    //   }

    //   logger.info(transformedSession)
    //   return transformedSession;
    // }),

    twoFactor({
      otpOptions: {
        async sendOTP({ user, otp }) {
          // await resend.emails.send({
          //   from: AUTH_CONFIG.from,
          //   to: user.email,
          //   subject: "Your OTP",
          //   html: `Your OTP is ${otp}`,
          // });
        },
      },
    }),
    phoneNumber({
      allowedAttempts: 3,
      sendOTP: async ({ phoneNumber, code }, request) => {
        console.log(`Sending OTP ${code} to ${phoneNumber}`)
        try {
          const res = await atSMS.send({
            to: [phoneNumber],
            message: `Your Alcora verification code is: ${code}`,
            // Optional: senderId: 'Alcora' (must be pre-approved on AT)
          })
          console.log('AT SMS sent:', res)
        } catch (err) {
          console.error('AT SMS failed:', err)
          // Optional: throw new Error("Failed to send SMS")
        }
      },
      signUpOnVerification: {
        getTempEmail: (phoneNumber) => {
          return `${phoneNumber}@alcorabooks.com`
        },
        getTempName: (phoneNumber) => {
          return phoneNumber
        }
      },
      callbackOnVerification: async ({ phoneNumber, user }, request) => {
        // ...
      },
      otpLength: 4,
      expiresIn: 60 * 5, //5mins
    }),
    openAPI(),
    bearer(),
    adminPlugin({
      ac: ac,
      roles,
      adminRoles: ["owner", "admin"],
      impersonationSessionDuration: 60 * 60 * 24 * 7,
    }),
    multiSession(),
    oAuthProxy(),
    nextCookies(),
  ],
} satisfies BetterAuthOptions);

export const createAuth = () => {
  return auth;
};

export type Auth = ReturnType<typeof createAuth>;

<!-- gh-comment-id:3028078881 --> @Qodestackr commented on GitHub (Jul 2, 2025): Mine works for some reason but its duck tapes 😆 ... What does not work is my logic that checks if its a signup or sign-in so I dont recreate Saleor resources like warehouseIds and channels... ```js import { betterAuth, logger, type BetterAuthOptions } from "better-auth"; import { bearer, admin as adminPlugin, multiSession, organization, twoFactor, oAuthProxy, openAPI, oidcProvider, customSession, phoneNumber, createAuthMiddleware, } from "better-auth/plugins"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { mapUserRoleToBusiness } from "./role-management"; import { APP_BASE_URL } from "@workspace/utils"; // import { reactInvitationEmail } from "@/components/emails/invitation"; import { ac, roles } from "./permissions"; // import { reactResetPasswordEmail } from "@/components/emails/reset-password-email"; // import { resend } from "@workspace/notifications"; import { nextCookies } from "better-auth/next-js"; // import { initializeOrganizationPermissions } from "@/services/permission-service"; import { generateId } from "@workspace/utils" import { setupSaleorResourcesForNewUser } from "@workspace/coremmerce/server-only"; import db, { PaymentMethodType } from "@workspace/db"; import { atSMS } from "./africastalking"; const ALLOWED_ROLES = [ "retailer", "wholesaler", "distributor", "user", "driver", "brand_owner", ] as const; const businessRoles = new Set([ "distributor", "retailer", "wholesaler", "brand_owner", ]); const AUTH_CONFIG = { from: "online@**.com", to: process.env.TEST_EMAIL || "", trustedOrigins: [ "http://46.202.130.243:3000", "http://46.202.130.243:7700", "http://46.202.130.243:8000", "https://commerce.**.com", "https://search.**.com", "https://**.com", "alcorabooks.com", "https://www.**.com", ], appName: "Alcorabooks", allowedRoles: ALLOWED_ROLES, }; export const auth: any = betterAuth({ trustedOrigins: AUTH_CONFIG.trustedOrigins, appName: AUTH_CONFIG.appName, secret: "....", database: prismaAdapter(db, { provider: "postgresql", }), logger: { level: "debug", disabled: false }, hooks: { after: createAuthMiddleware(async (ctx) => { // Handle newly authenticated users if (ctx.path.startsWith("/sign-up") || ctx.path.startsWith("/sign-in")) { const newSession = ctx.context.newSession; if (newSession) { ctx.setCookie( "Set-Cookie", `session_token=${ctx.context.authCookies.sessionToken}; Path=/; HttpOnly; SameSite=Strict` ); // Check if user has pending onboarding // const org = await getOrganizationByUserId(newSession.user.id); const org = {} as any; if (org && org.metadata?.pendingOnboarding) { // Store onboarding state in session.... } } } }), }, databaseHooks: { user: { create: { before: async (user, ctx) => { const role = ctx?.body?.additionalFields?.role ?? "user"; if (!ALLOWED_ROLES.includes(role)) { throw new Error("Invalid role assignment"); } return { data: { ...user, role } }; }, after: async (user, ctx) => { } }, }, session: { create: { before: async (session, ctx) => { const membership = await db.member.findFirst({ where: { userId: session.userId }, include: { organization: true }, orderBy: { createdAt: "asc" }, // prioritize oldest org if multiple }); if (!membership) { console.warn("No organization found for user:", session.userId); return { data: session }; // return session as-is, no org } return { data: { ...session, activeOrganizationId: membership.organization.id, }, }; }, after: async (session, ctx) => { console.log("Session after create:", session,); try { // 0) Bail early if this is just a sign‑in, not a sign‑up const isSignUp = Boolean(ctx?.context.newSession) && (ctx?.path.startsWith("/sign-up") || ctx?.path.startsWith("/auth/phone-number/verify")); if (!isSignUp) { logger.info("Sign‑in detected — skipping org & Saleor provisioning"); return; } // 1) Fetch user & role const user = await db.user.findUnique({ where: { id: session.userId } }); if (!user) return; // 2) Only business roles get orgs if (!businessRoles.has(user.role)) { logger.info(`Role=${user.role} — skipping org creation for B2C user`); return; } // 3) Check for existing org membership let membership = await db.member.findFirst({ where: { userId: user.id }, include: { organization: true }, }); // 4) True sign‑up + business role + no membership → create org + provision if (!membership) { const org = await db.organization.create({ data: { name: `${user.name}'s Organization`, slug: generateId(), businessType: mapUserRoleToBusiness(user.role), paymentMethod: PaymentMethodType.MPESA, members: { create: { userId: user.id, role: "OWNER" } }, }, }); // Update session await db.session.update({ where: { id: session.id }, data: { activeOrganizationId: org.id }, }); (session as any).activeOrganizationId = org.id; // Provision Saleor resources await setupSaleorResourcesForNewUser( user.id, org.id, session.id ); logger.info("New business sign‑up: org & Saleor provisioned"); } else { logger.info("Existing business user sign‑up? Membership found — skipping provisioning"); } } catch (error) { console.error("Error in session.create.after:", error); } }, }, }, }, user: { additionalFields: { role: { type: "string", required: true, defaultValue: "user", input: true, }, premium: { type: "boolean", required: false, defaultValue: false, input: false, }, // Phone number for SMS notifications not from better-auth auth plugins // @ref https://www.better-auth.com/docs/plugins/phone-number phoneNumber: { type: "string", required: false, input: true, }, enableSMS: { type: "boolean", required: false, defaultValue: true, input: true, }, }, changeEmail: { enabled: true, sendChangeEmailVerification: async ( { user, newEmail, url, token }, request ) => { console.log("TODO: CHANGE EMAIL"); }, }, }, session: { cookieCache: { enabled: true, maxAge: 60 * 60 * 24 * 7, }, expiresIn: 60 * 60 * 24 * 7, updateAge: 60 * 60 * 24, // (every 1 day the session expiration is updated) additionalFields: { saleorChannelId: { type: "string", required: false, }, saleorChannelSlug: { type: "string", required: false, }, organizationId: { type: "string", required: false, }, organizationName: { type: "string", required: false, }, organizationSlug: { type: "string", required: false, }, businessType: { type: "string", required: false, }, warehouseId: { type: "string", required: false, }, }, }, emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, async sendVerificationEmail({ user, url }) { // const verificationUrl = `${process.env.BETTER_AUTH_URL}/api/auth/verify-email?token=${token}&callbackURL=${process.env.EMAIL_VERIFICATION_CALLBACK_URL}`; // const res = await resend.emails.send({ // from: AUTH_CONFIG.from, // to: AUTH_CONFIG.to || user.email, // subject: "Verify your email address", // html: `<a href="${url}">Verify your email address</a>`, // }); console.log(user.email); }, }, // account: { // accountLinking: { // trustedProviders: ["google", "microsoft"], // }, // }, emailAndPassword: { enabled: true, // requireEmailVerification: true, // async sendResetPassword({ user, url }) { // await resend.emails.send({ // from: AUTH_CONFIG.from, // to: user.email, // subject: "Reset your password", // react: reactResetPasswordEmail({ // username: user.email, // resetLink: url, // }), // }); // }, }, socialProviders: { facebook: { clientId: process.env.FACEBOOK_CLIENT_ID || "", clientSecret: process.env.FACEBOOK_CLIENT_SECRET || "", }, google: { clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "", clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", }, microsoft: { clientId: process.env.MICROSOFT_CLIENT_ID || "", clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "", }, }, plugins: [ oidcProvider({ loginPage: "/sign-in", requirePKCE: true, consentPage: "/oauth/consent", metadata: { issuer: APP_BASE_URL, authorization_endpoint: `${APP_BASE_URL}/oauth/authorize`, token_endpoint: `${APP_BASE_URL}/oauth/token`, userinfo_endpoint: `${APP_BASE_URL}/userinfo`, jwks_uri: `${APP_BASE_URL}/.well-known/jwks.json`, response_types_supported: ["code"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"], }, // Allow Saleor as a client allowDynamicClientRegistration: true, }), organization({ allowUserToCreateOrganization: async (user) => { return businessRoles.has((user as any).role); }, membershipLimit: 200_0, organizationLimit: 10, ac: ac, roles: { owner: roles.owner, admin: roles.admin, wholesaler: roles.wholesaler, retailer: roles.retailer, bartender: roles.bartender, cashier: roles.cashier, finance: roles.finance, driver: roles.driver, }, // Custom schema configuration for Organization schema: { organization: { fields: { // Define how organization.metadata maps to our custom fields metadata: "metadata", }, // Additional fields/columns not automatically handled by the plugin additionalFields: { businessType: { type: "string", required: false, defaultValue: "", }, description: { type: "string", required: false, }, logo: { type: "string", required: false, }, city: { type: "string", required: false, }, address: { type: "string", required: false, }, phoneNumber: { type: "string", required: false, }, enableSMS: { type: "boolean", required: false, defaultValue: true, }, paymentMethod: { type: "string", required: false, }, subscriptionPlan: { type: "string", required: false, }, onboardingComplete: { type: "boolean", required: false, defaultValue: false, }, channel: { type: "string", required: false, defaultValue: true, }, }, }, }, async sendInvitationEmail(data) { // await resend.emails.send({ // from: AUTH_CONFIG.from, // to: data.email, // subject: "You've been invited to join an organization", // react: reactInvitationEmail({ // username: data.email, // invitedByUsername: data.inviter.user.name, // invitedByEmail: data.inviter.user.email, // teamName: data.organization.name, // inviteLink: // process.env.NODE_ENV === "development" // ? `http://localhost:3000/accept-invitation/${data.id}` // : `${process.env.BETTER_AUTH_URL || APP_BASE_URL // }/accept-invitation/${data.id}`, // }), // }); }, organizationCreation: { beforeCreate: async ({ organization, user }) => { const extendedOrg = { ...organization, slug: organization.slug || generateId(), paymentMethod: PaymentMethodType.MPESA, } console.log("ext-org.user", extendedOrg, user, user.id) return { data: extendedOrg, }; }, afterCreate: async ({ organization, user }) => { // TODO: Attach Saleor resources console.log(organization, "afterCreate") }, }, }), // customSession(async ({ user, session }) => { // // First, get the organization from the active organization ID // const org = await db.organization.findUnique({ // where: { id: (session as any)?.activeOrganizationId || "" }, // include: { // saleorChannels: { where: { isActive: true }, take: 1 }, // warehouses: { take: 1 }, // }, // }); // // Get the default channel // const defaultChannel = org?.saleorChannels?.[0]; // const defaultWarehouse = org?.warehouses?.[0]; // const transformedSession: any = { ...session }; // transformedSession.user = user; // transformedSession.role = (user as any)?.role; // transformedSession.organizationId = org?.id; // transformedSession.organizationName = org?.name; // transformedSession.organizationSlug = org?.slug; // transformedSession.businessType = org?.businessType; // // Adding chann properties last to ensure they aren't overridden // if (defaultChannel) { // transformedSession.saleorChannelId = defaultChannel.saleorChannelId; // transformedSession.saleorChannelSlug = defaultChannel.slug; // } // if (defaultWarehouse) { // transformedSession.warehouseId = defaultWarehouse.saleorWarehouseId; // } // logger.info(transformedSession) // return transformedSession; // }), twoFactor({ otpOptions: { async sendOTP({ user, otp }) { // await resend.emails.send({ // from: AUTH_CONFIG.from, // to: user.email, // subject: "Your OTP", // html: `Your OTP is ${otp}`, // }); }, }, }), phoneNumber({ allowedAttempts: 3, sendOTP: async ({ phoneNumber, code }, request) => { console.log(`Sending OTP ${code} to ${phoneNumber}`) try { const res = await atSMS.send({ to: [phoneNumber], message: `Your Alcora verification code is: ${code}`, // Optional: senderId: 'Alcora' (must be pre-approved on AT) }) console.log('AT SMS sent:', res) } catch (err) { console.error('AT SMS failed:', err) // Optional: throw new Error("Failed to send SMS") } }, signUpOnVerification: { getTempEmail: (phoneNumber) => { return `${phoneNumber}@alcorabooks.com` }, getTempName: (phoneNumber) => { return phoneNumber } }, callbackOnVerification: async ({ phoneNumber, user }, request) => { // ... }, otpLength: 4, expiresIn: 60 * 5, //5mins }), openAPI(), bearer(), adminPlugin({ ac: ac, roles, adminRoles: ["owner", "admin"], impersonationSessionDuration: 60 * 60 * 24 * 7, }), multiSession(), oAuthProxy(), nextCookies(), ], } satisfies BetterAuthOptions); export const createAuth = () => { return auth; }; export type Auth = ReturnType<typeof createAuth>; ```
Author
Owner

@dosubot[bot] commented on GitHub (Oct 1, 2025):

Hi, @MikeCodeur. I'm Dosu, and I'm helping the better-auth team manage their backlog and am marking this issue as stale.

Issue Summary:

  • You reported that using the customSession plugin before the organization plugin causes the activeOrganizationId property to be missing from the session object due to TypeScript type inference conflicts.
  • This is a known limitation of Better Auth's plugin/type system rather than a bug.
  • A documented workaround involves defining Better Auth options separately to ensure proper type inference.
  • Another user shared an example setup combining these plugins, noting some related sign-up vs sign-in logic challenges.
  • The issue remains a common pain point but has an established resolution approach.

Next Steps:

  • Please confirm if this issue is still relevant with the latest version of better-auth by commenting here to keep the discussion open.
  • Otherwise, I will automatically close this issue in 7 days.

Thanks for your understanding and contribution!

<!-- gh-comment-id:3357097618 --> @dosubot[bot] commented on GitHub (Oct 1, 2025): Hi, @MikeCodeur. I'm [Dosu](https://dosu.dev), and I'm helping the better-auth team manage their backlog and am marking this issue as stale. **Issue Summary:** - You reported that using the customSession plugin before the organization plugin causes the activeOrganizationId property to be missing from the session object due to TypeScript type inference conflicts. - This is a known limitation of Better Auth's plugin/type system rather than a bug. - A documented workaround involves defining Better Auth options separately to ensure proper type inference. - Another user shared an example setup combining these plugins, noting some related sign-up vs sign-in logic challenges. - The issue remains a common pain point but has an established resolution approach. **Next Steps:** - Please confirm if this issue is still relevant with the latest version of better-auth by commenting here to keep the discussion open. - Otherwise, I will automatically close this issue in 7 days. Thanks for your understanding and contribution!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#9532