[SSO] Users signing in with different methods don't get added to the organization #2043

Closed
opened 2026-03-13 09:22:48 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @jakst on GitHub (Sep 29, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Set up Better Auth with the SSO plugin
  2. Create an organization and set up an SSO configuration with a domain for it
  3. Sign up a user with the configured SSO domain, but using a different sign-in method

The user will not be invited to the organization tied to the domain of the users email-address.

Note 1: I had to set account.accountLinking.enabled: true and plugins[sso].trustEmailVerified: true in order to get signup to work with SSO. This might play a role in the beahviour here.

Note 2: I can't upgrade to latest better-auth, because then SAML config is required when creating an SSO configuration, and we're not using SAML, just regular OIDC.

Current vs. Expected behavior

I expect either of these behaviours when using the SSO plugin:

  1. Signing in/up with other auth methods is disabled when SSO is configured for a domain
  2. If the domain matches, users who sign up with other methods than SSO still get added to the organization.

What version of Better Auth are you using?

1.3.9

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 24.6.0: Mon Jul 14 11:28:30 PDT 2025; root:xnu-11417.140.69~1/RELEASE_ARM64_T6030",
    "release": "24.6.0",
    "cpuCount": 12,
    "cpuModel": "Apple M3 Pro",
    "totalMemory": "36.00 GB",
    "freeMemory": "0.16 GB"
  },
  "node": {
    "version": "v24.8.0",
    "env": "development"
  },
  "packageManager": {
    "name": "pnpm",
    "version": "10.17.1"
  },
  "frameworks": [
    {
      "name": "solid",
      "version": "1.9.9"
    }
  ],
  "databases": [
    {
      "name": "drizzle",
      "version": "0.44.5"
    }
  ],
  "betterAuth": {
    "version": "1.3.9",
    "config": null
  }
}

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

Backend

Auth config (if applicable)

import { betterAuth } from "better-auth"

export const auth = betterAuth({
  baseURL,
  secret,

  session: {
    cookieCache: {
      enabled: true,
      maxAge: 15 * 60,
    },
  },

  user: {
    deleteUser: {
      enabled: true,
    },
  },

  account: {
    accountLinking: {
      enabled: true,
    },
  },

  advanced: {
    useSecureCookies: true,
  },

  emailAndPassword: {
    enabled: false
  },


  socialProviders: {
    github,
    google,
  },
  
  plugin: [
    admin(),
    oAuthProxy({ currentURL: baseURL }),
    organization({ invitationExpiresIn: INVITATION_EXPIRES_DAYS * 24 * 60 * 60 }),
    sso({ trustEmailVerified: true }),
    emailOTP({/* ... */}),
  ]
});

Additional context

No response

Originally created by @jakst on GitHub (Sep 29, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Set up Better Auth with the SSO plugin 2. Create an organization and set up an SSO configuration with a domain for it 3. Sign up a user with the configured SSO domain, but using a different sign-in method The user will not be invited to the organization tied to the domain of the users email-address. Note 1: I had to set `account.accountLinking.enabled: true` and `plugins[sso].trustEmailVerified: true` in order to get signup to work with SSO. This might play a role in the beahviour here. Note 2: I can't upgrade to latest better-auth, because then SAML config is required when creating an SSO configuration, and we're not using SAML, just regular OIDC. ### Current vs. Expected behavior I expect either of these behaviours when using the SSO plugin: 1. Signing in/up with other auth methods is disabled when SSO is configured for a domain 2. If the domain matches, users who sign up with other methods than SSO still get added to the organization. ### What version of Better Auth are you using? 1.3.9 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 24.6.0: Mon Jul 14 11:28:30 PDT 2025; root:xnu-11417.140.69~1/RELEASE_ARM64_T6030", "release": "24.6.0", "cpuCount": 12, "cpuModel": "Apple M3 Pro", "totalMemory": "36.00 GB", "freeMemory": "0.16 GB" }, "node": { "version": "v24.8.0", "env": "development" }, "packageManager": { "name": "pnpm", "version": "10.17.1" }, "frameworks": [ { "name": "solid", "version": "1.9.9" } ], "databases": [ { "name": "drizzle", "version": "0.44.5" } ], "betterAuth": { "version": "1.3.9", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth" export const auth = betterAuth({ baseURL, secret, session: { cookieCache: { enabled: true, maxAge: 15 * 60, }, }, user: { deleteUser: { enabled: true, }, }, account: { accountLinking: { enabled: true, }, }, advanced: { useSecureCookies: true, }, emailAndPassword: { enabled: false }, socialProviders: { github, google, }, plugin: [ admin(), oAuthProxy({ currentURL: baseURL }), organization({ invitationExpiresIn: INVITATION_EXPIRES_DAYS * 24 * 60 * 60 }), sso({ trustEmailVerified: true }), emailOTP({/* ... */}), ] }); ``` ### Additional context _No response_
Author
Owner

@natetewelde commented on GitHub (Sep 30, 2025):

Have you looked into hooks? The after hook might be able to accomplish exactly what you’re looking for.

@natetewelde commented on GitHub (Sep 30, 2025): Have you looked into hooks? The after hook might be able to accomplish exactly what you’re looking for.
Author
Owner

@jakst commented on GitHub (Sep 30, 2025):

Have you looked into hooks? The after hook might be able to accomplish exactly what you’re looking for.

I did give it a thought, but couldn't figure out a good setup. Which event would you hook into and what would you do in it in this case?

@jakst commented on GitHub (Sep 30, 2025): > Have you looked into hooks? The after hook might be able to accomplish exactly what you’re looking for. I did give it a thought, but couldn't figure out a good setup. Which event would you hook into and what would you do in it in this case?
Author
Owner

@natetewelde commented on GitHub (Sep 30, 2025):

I’m using the drizzle db adapter but this was my early implementation to have users auto assigned to an organization using a hook. This ideally should be how you’re able to detect a login by other means than SSO and domain match.

`hooks: {
after: createAuthMiddleware(async (ctx) => {
try {
const { newSession } = ctx.context

    if (
      !ctx.path.startsWith('/sign-in') ||
      !newSession?.user ||
      !newSession.session?.token
    ) {
      return
    }

    const user = newSession.user
    const session = newSession.session

    // First, check if user is already a member of any organization
    const existingMembership = await db.query.members.findFirst({
      where: (m, { eq }) => eq(m.userId, user.id)
    })

    if (existingMembership) {
      // User is already a member, set their active organization
      await db
        .update(authSchema.sessions)
        .set({ activeOrganizationId: existingMembership.organizationId })
        .where(eq(authSchema.sessions.token, session.token))

      if (!isProd)
        console.log(
          '[AUTH] Session updated with existing membership organization:',
          existingMembership.organizationId
        )
      return
    }

    // Check if user has any pending invitations
    const pendingInvitation = await db.query.invitations.findFirst({
      where: (inv, { and, eq }) =>
        and(eq(inv.email, user.email), eq(inv.status, 'pending'))
    })

    if (pendingInvitation) {
      // User has pending invitation, don't auto-assign to organization
      // They should go through the invitation acceptance flow
      if (!isProd)
        console.log(
          '[AUTH] User has pending invitation, skipping auto-assignment'
        )
      return
    }

    // Fall back to SSO domain matching for organizations with SSO enabled
    const domain = user.email.split('@')[1]
    if (!domain) {
      console.warn('[AUTH] Could not parse domain from email.')
      return
    }

    const ssoProvider = await db.query.ssoProviders.findFirst({
      where: (sso, { eq }) => eq(sso.domain, domain)
    })

    if (!ssoProvider || !ssoProvider.organizationId) {
      if (!isProd)
        console.log(
          `[AUTH] No SSO provider found for domain: ${domain}. User will need to be invited to an organization.`
        )
      return
    }

    const org = await db.query.organizations.findFirst({
      where: (orgs, { eq }) => eq(orgs.id, ssoProvider.organizationId!)
    })

    if (!org) {
      console.warn(
        `[AUTH] Organization not found for id: ${ssoProvider.organizationId}`
      )
      return
    }

    if (!org) {
      console.warn(`[AUTH] No organization found for domain: ${domain}`)
      return
    }

    const alreadyMember = await db.query.members.findFirst({
      where: (m, { and, eq }) =>
        and(eq(m.userId, user.id), eq(m.organizationId, org.id))
    })

    if (!alreadyMember) {
      await db.insert(authSchema.members).values({
        id: nanoid(),
        createdAt: new Date(),
        userId: user.id,
        organizationId: org.id,
        role: 'member'
      })

      await grantDefaultPermissionsFromScope({
        sourceScope: 'organization',
        sourceScopeId: org.id,
        userId: user.id,
        assignedBy: user.id
      })

      if (!isProd)
        console.log('[AUTH] Member created + permissions granted.')
    }

    await db
      .update(authSchema.sessions)
      .set({ activeOrganizationId: org.id })
      .where(eq(authSchema.sessions.token, session.token))

    if (!isProd)
      console.log(
        '[AUTH] Session updated with activeOrganizationId:',
        org.id
      )
  } catch (error) {
    console.error('[AUTH] Error in after hook:', error)
  }
})

}
}) `

@natetewelde commented on GitHub (Sep 30, 2025): I’m using the drizzle db adapter but this was my early implementation to have users auto assigned to an organization using a hook. This ideally should be how you’re able to detect a login by other means than SSO and domain match. `hooks: { after: createAuthMiddleware(async (ctx) => { try { const { newSession } = ctx.context if ( !ctx.path.startsWith('/sign-in') || !newSession?.user || !newSession.session?.token ) { return } const user = newSession.user const session = newSession.session // First, check if user is already a member of any organization const existingMembership = await db.query.members.findFirst({ where: (m, { eq }) => eq(m.userId, user.id) }) if (existingMembership) { // User is already a member, set their active organization await db .update(authSchema.sessions) .set({ activeOrganizationId: existingMembership.organizationId }) .where(eq(authSchema.sessions.token, session.token)) if (!isProd) console.log( '[AUTH] Session updated with existing membership organization:', existingMembership.organizationId ) return } // Check if user has any pending invitations const pendingInvitation = await db.query.invitations.findFirst({ where: (inv, { and, eq }) => and(eq(inv.email, user.email), eq(inv.status, 'pending')) }) if (pendingInvitation) { // User has pending invitation, don't auto-assign to organization // They should go through the invitation acceptance flow if (!isProd) console.log( '[AUTH] User has pending invitation, skipping auto-assignment' ) return } // Fall back to SSO domain matching for organizations with SSO enabled const domain = user.email.split('@')[1] if (!domain) { console.warn('[AUTH] Could not parse domain from email.') return } const ssoProvider = await db.query.ssoProviders.findFirst({ where: (sso, { eq }) => eq(sso.domain, domain) }) if (!ssoProvider || !ssoProvider.organizationId) { if (!isProd) console.log( `[AUTH] No SSO provider found for domain: ${domain}. User will need to be invited to an organization.` ) return } const org = await db.query.organizations.findFirst({ where: (orgs, { eq }) => eq(orgs.id, ssoProvider.organizationId!) }) if (!org) { console.warn( `[AUTH] Organization not found for id: ${ssoProvider.organizationId}` ) return } if (!org) { console.warn(`[AUTH] No organization found for domain: ${domain}`) return } const alreadyMember = await db.query.members.findFirst({ where: (m, { and, eq }) => and(eq(m.userId, user.id), eq(m.organizationId, org.id)) }) if (!alreadyMember) { await db.insert(authSchema.members).values({ id: nanoid(), createdAt: new Date(), userId: user.id, organizationId: org.id, role: 'member' }) await grantDefaultPermissionsFromScope({ sourceScope: 'organization', sourceScopeId: org.id, userId: user.id, assignedBy: user.id }) if (!isProd) console.log('[AUTH] Member created + permissions granted.') } await db .update(authSchema.sessions) .set({ activeOrganizationId: org.id }) .where(eq(authSchema.sessions.token, session.token)) if (!isProd) console.log( '[AUTH] Session updated with activeOrganizationId:', org.id ) } catch (error) { console.error('[AUTH] Error in after hook:', error) } }) } }) `
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2043