[BUG] SSO SAML Redirect does not function as intended #1578

Closed
opened 2026-03-13 08:49:45 -05:00 by GiteaMirror · 5 comments
Owner

Originally created by @natetewelde on GitHub (Jul 25, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Register a SAML SSO Provider with all the required fields.
  2. Try signing in to the SSO Provider.

const handleSSOSignIn = async () => { try { await authClient.signIn.sso({ organizationSlug: orgSlug, callbackURL: '/select-organization' }) } catch (error) { console.error('SSO sign-in error:', error) form.setError('root', { message: 'Failed to sign in with SSO' }) } }

Current vs. Expected behavior

Using social sign in methods ask you to put in a callbackURL that handles redirect perfectly, however sso sign in methods do not utilize the callbackURL as intended. It's like the callbackURL that the SSO Sign in method is using is the one from the registered sso provider, not the one being passed in the function call.

What version of Better Auth are you using?

1.3.3

Provide environment information

-Browser [Chrome]
-OS [Windows]
-SSO Provider [Entra]

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

Package, Backend, Client, Other

Auth config (if applicable)

import { betterAuth } from 'better-auth'
import { createAuthMiddleware } from 'better-auth/api'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { db, authSchema, eq } from '@wci/wv-db-schemas'
import { sso } from '@better-auth/sso'

import { nextCookies } from 'better-auth/next-js'
import { admin, apiKey, organization } from 'better-auth/plugins'
import { nanoid } from '@reduxjs/toolkit'
import { grantDefaultPermissionsFromScope } from '../../utils/permissions/grant-default-permissions-from-scope'

const isProd = process.env.NODE_ENV === 'production'

export const auth = betterAuth({
  socialProviders: {
    microsoft: {
      clientId: process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID as string,
      clientSecret: process.env.MICROSOFT_CLIENT_SECRET as string,
      tenantId: process.env.NEXT_PUBLIC_MICROSOFT_TENANT_ID as string,
      scope: [
        'openid',
        'profile',
        'email',
        'offline_access',
        'User.Read',
        'Group.Read.All'
      ]
    }
  },
  account: {
    accountLinking: {
      enabled: true,
      trustedProviders: ['microsoft']
    }
  },
  advanced: {
    cookiePrefix: isProd ? 'prod_' : 'dev_',
    crossSubDomainCookies: {
      enabled: true,
      domain: isProd ? '.worldvue.com' : 'localhost'
    },
    defaultCookieAttributes: {
      secure: isProd,
      httpOnly: true,
      sameSite: isProd ? 'None' : 'Lax'
    }
  },
  database: drizzleAdapter(db, {
    provider: 'pg',
    schema: {
      ...authSchema,
      user: authSchema.users,
      session: authSchema.sessions,
      account: authSchema.accounts,
      verification: authSchema.verifications
    },
    usePlural: true
  }),
  emailAndPassword: {
    enabled: true
  },
  plugins: [
    nextCookies(),
    sso(),
    apiKey(),
    admin(),
    organization({
      teams: {
        enabled: true,
        allowRemovingAllTeams: false
      }
    })
  ],
  user: {
    additionalFields: {
      isSuperAdmin: {
        type: 'boolean'
      },
      title: {
        type: 'string'
      }
    }
  },
  session: {
    additionalFields: {
      activeOrganizationId: {
        type: 'string'
      }
    }
  },
  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

        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) {
          console.warn(
            `[AUTH] Could not resolve organization for domain: ${domain}`
          )
          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)
      }
    })
  }
})

Additional context

It seems like the callbackURL that's being used for the redirect is the one from the registered sso provider, not the callback url being passed to the sign in function like it's being done with a social sign-in.

Originally created by @natetewelde on GitHub (Jul 25, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Register a SAML SSO Provider with all the required fields. 2. Try signing in to the SSO Provider. `const handleSSOSignIn = async () => { try { await authClient.signIn.sso({ organizationSlug: orgSlug, callbackURL: '/select-organization' }) } catch (error) { console.error('SSO sign-in error:', error) form.setError('root', { message: 'Failed to sign in with SSO' }) } }` ### Current vs. Expected behavior Using social sign in methods ask you to put in a callbackURL that handles redirect perfectly, however sso sign in methods do not utilize the callbackURL as intended. It's like the callbackURL that the SSO Sign in method is using is the one from the registered sso provider, not the one being passed in the function call. ### What version of Better Auth are you using? 1.3.3 ### Provide environment information ```bash -Browser [Chrome] -OS [Windows] -SSO Provider [Entra] ``` ### Which area(s) are affected? (Select all that apply) Package, Backend, Client, Other ### Auth config (if applicable) ```typescript import { betterAuth } from 'better-auth' import { createAuthMiddleware } from 'better-auth/api' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { db, authSchema, eq } from '@wci/wv-db-schemas' import { sso } from '@better-auth/sso' import { nextCookies } from 'better-auth/next-js' import { admin, apiKey, organization } from 'better-auth/plugins' import { nanoid } from '@reduxjs/toolkit' import { grantDefaultPermissionsFromScope } from '../../utils/permissions/grant-default-permissions-from-scope' const isProd = process.env.NODE_ENV === 'production' export const auth = betterAuth({ socialProviders: { microsoft: { clientId: process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID as string, clientSecret: process.env.MICROSOFT_CLIENT_SECRET as string, tenantId: process.env.NEXT_PUBLIC_MICROSOFT_TENANT_ID as string, scope: [ 'openid', 'profile', 'email', 'offline_access', 'User.Read', 'Group.Read.All' ] } }, account: { accountLinking: { enabled: true, trustedProviders: ['microsoft'] } }, advanced: { cookiePrefix: isProd ? 'prod_' : 'dev_', crossSubDomainCookies: { enabled: true, domain: isProd ? '.worldvue.com' : 'localhost' }, defaultCookieAttributes: { secure: isProd, httpOnly: true, sameSite: isProd ? 'None' : 'Lax' } }, database: drizzleAdapter(db, { provider: 'pg', schema: { ...authSchema, user: authSchema.users, session: authSchema.sessions, account: authSchema.accounts, verification: authSchema.verifications }, usePlural: true }), emailAndPassword: { enabled: true }, plugins: [ nextCookies(), sso(), apiKey(), admin(), organization({ teams: { enabled: true, allowRemovingAllTeams: false } }) ], user: { additionalFields: { isSuperAdmin: { type: 'boolean' }, title: { type: 'string' } } }, session: { additionalFields: { activeOrganizationId: { type: 'string' } } }, 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 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) { console.warn( `[AUTH] Could not resolve organization for domain: ${domain}` ) 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) } }) } }) ``` ### Additional context It seems like the callbackURL that's being used for the redirect is the one from the registered sso provider, not the callback url being passed to the sign in function like it's being done with a social sign-in.
GiteaMirror added the bug label 2026-03-13 08:49:45 -05:00
Author
Owner

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

This is a known issue in Better Auth v1.3.3—SSO (SAML) sign-in does not always respect the callbackURL passed to signIn.sso(), and instead may use the callback URL registered with the SSO provider. This behavior is different from social sign-in, where the callbackURL parameter is honored as expected. The root cause is that the SSO plugin's redirect logic can prioritize the provider's registered value or mishandle the callbackURL propagation through the state, especially if the callbackURL is not absolute or not included in trustedOrigins. This has been reported by other users and acknowledged by maintainers as a bug in earlier versions.

Recent updates and pull requests have improved callbackURL handling for SSO, OAuth, and social sign-in flows, ensuring the callbackURL parameter is respected and properly propagated (see PR #2740, PR #1176, PR #2640). There is no official workaround for v1.3.3, but you can try passing an absolute URL for callbackURL (e.g., https://your-frontend.com/select-organization) and make sure your frontend origin is included in the trustedOrigins array. If that doesn't resolve the issue, upgrading to the latest version of Better Auth is recommended, as these fixes are not documented for v1.3.3 and are likely only available in newer releases.

References:

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Jul 25, 2025): <!-- Answer --> This is a known issue in Better Auth v1.3.3—SSO (SAML) sign-in does not always respect the callbackURL passed to signIn.sso(), and instead may use the callback URL registered with the SSO provider. This behavior is different from social sign-in, where the callbackURL parameter is honored as expected. The root cause is that the SSO plugin's redirect logic can prioritize the provider's registered value or mishandle the callbackURL propagation through the state, especially if the callbackURL is not absolute or not included in trustedOrigins. This has been reported by other users and acknowledged by maintainers as a bug in earlier versions. Recent updates and pull requests have improved callbackURL handling for SSO, OAuth, and social sign-in flows, ensuring the callbackURL parameter is respected and properly propagated (see [PR #2740](https://github.com/better-auth/better-auth/pull/2740), [PR #1176](https://github.com/better-auth/better-auth/pull/1176), [PR #2640](https://github.com/better-auth/better-auth/pull/2640)). There is no official workaround for v1.3.3, but you can try passing an absolute URL for callbackURL (e.g., `https://your-frontend.com/select-organization`) and make sure your frontend origin is included in the trustedOrigins array. If that doesn't resolve the issue, upgrading to the latest version of Better Auth is recommended, as these fixes are not documented for v1.3.3 and are likely only available in newer releases. References: - [CallbackURL handling in SSO plugin](https://github.com/better-auth/better-auth/blob/f99305b29eb096186a0f85100e01f721d208f2f0/packages/better-auth/src/plugins/sso/index.ts) - [Recent callbackURL fixes and improvements](https://github.com/better-auth/better-auth/pull/2740) - [Trusted origins and absolute callback URLs](https://github.com/better-auth/better-auth/issues/3407) <!-- 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/a2857694-4fdb-49db-acc5-38a06c8afa59?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/a2857694-4fdb-49db-acc5-38a06c8afa59?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/a2857694-4fdb-49db-acc5-38a06c8afa59?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/a2857694-4fdb-49db-acc5-38a06c8afa59?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/a2857694-4fdb-49db-acc5-38a06c8afa59?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/a2857694-4fdb-49db-acc5-38a06c8afa59?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/a2857694-4fdb-49db-acc5-38a06c8afa59?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/3615)
Author
Owner

@natetewelde commented on GitHub (Jul 25, 2025):

This is new but now im starting to get this error:

POST /api/auth/sso/saml2/callback/ 302 in 2505ms
GET /api/auth/sso/saml2/callback/?error=account_not_found 404 in 54ms

@natetewelde commented on GitHub (Jul 25, 2025): This is new but now im starting to get this error: POST /api/auth/sso/saml2/callback/<providerId> 302 in 2505ms GET /api/auth/sso/saml2/callback/<providerId>?error=account_not_found 404 in 54ms
Author
Owner

@gonzalochale commented on GitHub (Jul 31, 2025):

This is new but now im starting to get this error:

POST /api/auth/sso/saml2/callback/ 302 in 2505ms GET /api/auth/sso/saml2/callback/?error=account_not_found 404 in 54ms

the account isn’t found because the accountId is either missing or malformed during creation, did you manage to solve it?

@gonzalochale commented on GitHub (Jul 31, 2025): > This is new but now im starting to get this error: > > POST /api/auth/sso/saml2/callback/ 302 in 2505ms GET /api/auth/sso/saml2/callback/?error=account_not_found 404 in 54ms the account isn’t found because the accountId is either missing or malformed during creation, did you manage to solve it?
Author
Owner

@natetewelde commented on GitHub (Jul 31, 2025):

@gonzalochale so as it turns out the SAML attributes it was looking for to create the id for the account were not being parsed correctly from the SAML response due to the fallbacks for parsing the different attribute fields. NameID is typically the users email and this wasn’t being read in as a fallback which would cause the account creation to generate a bunch of nulls.

@natetewelde commented on GitHub (Jul 31, 2025): @gonzalochale so as it turns out the SAML attributes it was looking for to create the id for the account were not being parsed correctly from the SAML response due to the fallbacks for parsing the different attribute fields. NameID is typically the users email and this wasn’t being read in as a fallback which would cause the account creation to generate a bunch of nulls.
Author
Owner

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

Hi, @natetewelde. 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 in Better Auth v1.3.3, the SSO SAML sign-in method ignores the callbackURL passed during sign-in, defaulting to the provider-registered callbackURL.
  • This caused redirect issues, which the maintainers confirmed as a known problem in v1.3.3.
  • Recent PRs have improved callbackURL handling in newer versions, and upgrading resolves this issue.
  • You later encountered an "account_not_found" error due to SAML attributes not being parsed correctly for account ID creation, specifically the NameID fallback parsing.
  • The issue was clarified but no further unresolved action was indicated.

Next Steps:

  • Please confirm if this issue is still relevant with the latest version of better-auth; if so, feel free to comment to keep the discussion open.
  • Otherwise, I will automatically close this issue in 7 days.

Thanks for your understanding and contribution!

@dosubot[bot] commented on GitHub (Oct 30, 2025): Hi, @natetewelde. 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 in Better Auth v1.3.3, the SSO SAML sign-in method ignores the callbackURL passed during sign-in, defaulting to the provider-registered callbackURL. - This caused redirect issues, which the maintainers confirmed as a known problem in v1.3.3. - Recent PRs have improved callbackURL handling in newer versions, and upgrading resolves this issue. - You later encountered an "account_not_found" error due to SAML attributes not being parsed correctly for account ID creation, specifically the NameID fallback parsing. - The issue was clarified but no further unresolved action was indicated. **Next Steps:** - Please confirm if this issue is still relevant with the latest version of better-auth; if so, feel free to comment 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#1578