[GH-ISSUE #4862] ERROR [Better Auth]: SyntaxError SyntaxError: "[object Object]" is not valid JSON #18723

Closed
opened 2026-04-15 17:21:01 -05:00 by GiteaMirror · 8 comments
Owner

Originally created by @natetewelde on GitHub (Sep 23, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/4862

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Register SSO Provider with the given schema
  2. Try to sign-in via said schema

Current vs. Expected behavior

Expected behavior is to be able to sign in directly after successfully registering an SSO provider.

Current behavior is non-safe JSON parsing on a text field that's storing an object technically.

What version of Better Auth are you using?

1.3.15

System info

{
  "system": {
    "platform": "win32",
    "arch": "x64",
    "version": "Windows 11 Pro",
    "release": "10.0.26100",
    "cpuCount": 28,
    "cpuModel": "Intel(R) Core(TM) i7-14700",
    "totalMemory": "31.69 GB",
    "freeMemory": "9.83 GB"
  },
  "node": {
    "version": "v23.11.0",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "10.9.3"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "^15.4.1"
    },
    {
      "name": "react",
      "version": "^18.3.1"
    }
  ],
  "databases": [
    {
      "name": "drizzle",
      "version": "^0.41.0"
    }
  ],
  "betterAuth": {
    "version": "^1.3.14",
    "config": null
  }
}

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

Client, Backend, Types

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'
import { getRuntimeEnv } from '../env'

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

export const auth = betterAuth({
  // Use getRuntimeEnv for the auth secret to handle build vs runtime properly
  secret: getRuntimeEnv(
    'BETTER_AUTH_SECRET',
    'placeholder_auth_secret_for_build'
  ),
  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

        // 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)
      }
    })
  }
})

Additional context

No response

Originally created by @natetewelde on GitHub (Sep 23, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/4862 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Register SSO Provider with the given schema 2. Try to sign-in via said schema ### Current vs. Expected behavior Expected behavior is to be able to sign in directly after successfully registering an SSO provider. Current behavior is non-safe JSON parsing on a text field that's storing an object technically. ### What version of Better Auth are you using? 1.3.15 ### System info ```bash { "system": { "platform": "win32", "arch": "x64", "version": "Windows 11 Pro", "release": "10.0.26100", "cpuCount": 28, "cpuModel": "Intel(R) Core(TM) i7-14700", "totalMemory": "31.69 GB", "freeMemory": "9.83 GB" }, "node": { "version": "v23.11.0", "env": "development" }, "packageManager": { "name": "npm", "version": "10.9.3" }, "frameworks": [ { "name": "next", "version": "^15.4.1" }, { "name": "react", "version": "^18.3.1" } ], "databases": [ { "name": "drizzle", "version": "^0.41.0" } ], "betterAuth": { "version": "^1.3.14", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Client, Backend, Types ### 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' import { getRuntimeEnv } from '../env' const isProd = process.env.NODE_ENV === 'production' export const auth = betterAuth({ // Use getRuntimeEnv for the auth secret to handle build vs runtime properly secret: getRuntimeEnv( 'BETTER_AUTH_SECRET', 'placeholder_auth_secret_for_build' ), 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 // 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) } }) } }) ``` ### Additional context _No response_
GiteaMirror added the lockedbug labels 2026-04-15 17:21:01 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Sep 23, 2025):

The error happens because the SSO provider config (like oidcConfig) is expected to be stored as a JSON string in the database, but in your case, it's likely being stored as an object. When Better Auth tries to parse it with JSON.parse, it fails on [object Object] since that's not valid JSON [source].

To fix this in v1.3.15, make sure that when you register an SSO provider, any config fields (like oidcConfig or samlConfig) are always serialized with JSON.stringify before being saved to the DB. If you manipulate the DB directly, double-check that these fields are strings, not objects.

Recent versions of Better Auth (after 1.3.15) have improved defensive parsing and type safety for these flows, so upgrading is recommended if possible [source].

If you need to patch existing data, you can run a script to update any object values in the relevant DB columns to their JSON string equivalents:

// Example: Fix all SSO providers with object configs
const providers = await db.query.ssoProviders.findMany();
for (const provider of providers) {
  if (typeof provider.oidcConfig === 'object') {
    await db.update(authSchema.ssoProviders)
      .set({ oidcConfig: JSON.stringify(provider.oidcConfig) })
      .where(eq(authSchema.ssoProviders.id, provider.id));
  }
}

If you still see the error after this, check your registration flow and any DB migrations for places where configs might be stored as objects instead of strings.

To reply, just mention @dosu.


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

<!-- gh-comment-id:3325725533 --> @dosubot[bot] commented on GitHub (Sep 23, 2025): <!-- Answer --> The error happens because the SSO provider config (like `oidcConfig`) is expected to be stored as a JSON string in the database, but in your case, it's likely being stored as an object. When Better Auth tries to parse it with `JSON.parse`, it fails on `[object Object]` since that's not valid JSON [[source]](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/packages/better-auth/src/plugins/sso/index.ts). To fix this in v1.3.15, make sure that when you register an SSO provider, any config fields (like `oidcConfig` or `samlConfig`) are always serialized with `JSON.stringify` before being saved to the DB. If you manipulate the DB directly, double-check that these fields are strings, not objects. Recent versions of Better Auth (after 1.3.15) have improved defensive parsing and type safety for these flows, so upgrading is recommended if possible [[source]](https://github.com/better-auth/better-auth/pull/4828). If you need to patch existing data, you can run a script to update any object values in the relevant DB columns to their JSON string equivalents: ```js // Example: Fix all SSO providers with object configs const providers = await db.query.ssoProviders.findMany(); for (const provider of providers) { if (typeof provider.oidcConfig === 'object') { await db.update(authSchema.ssoProviders) .set({ oidcConfig: JSON.stringify(provider.oidcConfig) }) .where(eq(authSchema.ssoProviders.id, provider.id)); } } ``` If you still see the error after this, check your registration flow and any DB migrations for places where configs might be stored as objects instead of strings. <!-- 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/dab6ab38-3fbd-4767-a012-a43be1d217b8?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/dab6ab38-3fbd-4767-a012-a43be1d217b8?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/dab6ab38-3fbd-4767-a012-a43be1d217b8?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/dab6ab38-3fbd-4767-a012-a43be1d217b8?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/dab6ab38-3fbd-4767-a012-a43be1d217b8?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/dab6ab38-3fbd-4767-a012-a43be1d217b8?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/dab6ab38-3fbd-4767-a012-a43be1d217b8?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/4862)
Author
Owner

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

@dosu ive tried patching both ways, making the configs an object, and then making them back to JSON strings. nothing fixes the actual way that better-auth is trying to parse the config.

<!-- gh-comment-id:3325806488 --> @natetewelde commented on GitHub (Sep 23, 2025): @dosu ive tried patching both ways, making the configs an object, and then making them back to JSON strings. nothing fixes the actual way that better-auth is trying to parse the config.
Author
Owner

@dosubot[bot] commented on GitHub (Sep 23, 2025):

The persistent error means the value in your database's oidcConfig column is still not a valid JSON string when Better Auth tries to parse it. In v1.3.15, the code expects a string and blindly calls JSON.parse—if the value is an object or something like [object Object], it will always fail [source].

To debug, inspect the actual stored value for a failing SSO provider using a DB query tool. It should look like this:

{"issuer":"...","clientId":"...","clientSecret":"..."}

If you see [object Object] or anything else, update it directly:

// Example: Patch all SSO providers with invalid configs
const providers = await db.query.ssoProviders.findMany();
for (const provider of providers) {
  if (typeof provider.oidcConfig === 'object' || provider.oidcConfig === '[object Object]') {
    await db.update(authSchema.ssoProviders)
      .set({ oidcConfig: JSON.stringify(provider.oidcConfig) })
      .where(eq(authSchema.ssoProviders.id, provider.id));
  }
}

Also, confirm the DB column type is text or string, not jsonb or another type.

If you can't upgrade to a newer Better Auth version (which has safer parsing), consider wrapping the parsing in a try/catch locally to log and handle bad values. If the issue persists, please share the exact value stored in the column and any error stack trace—this will help pinpoint the problem.

To reply, just mention @dosu.


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

<!-- gh-comment-id:3325812803 --> @dosubot[bot] commented on GitHub (Sep 23, 2025): <!-- Answer --> The persistent error means the value in your database's `oidcConfig` column is still not a valid JSON string when Better Auth tries to parse it. In v1.3.15, the code expects a string and blindly calls `JSON.parse`—if the value is an object or something like `[object Object]`, it will always fail [[source]](https://github.com/better-auth/better-auth/blob/f6cbdcc84ee5d2971fdcc8b23ff7c174f88cf45b/packages/better-auth/src/plugins/sso/index.ts). To debug, inspect the actual stored value for a failing SSO provider using a DB query tool. It should look like this: ```json {"issuer":"...","clientId":"...","clientSecret":"..."} ``` If you see `[object Object]` or anything else, update it directly: ```js // Example: Patch all SSO providers with invalid configs const providers = await db.query.ssoProviders.findMany(); for (const provider of providers) { if (typeof provider.oidcConfig === 'object' || provider.oidcConfig === '[object Object]') { await db.update(authSchema.ssoProviders) .set({ oidcConfig: JSON.stringify(provider.oidcConfig) }) .where(eq(authSchema.ssoProviders.id, provider.id)); } } ``` Also, confirm the DB column type is `text` or `string`, not `jsonb` or another type. If you can't upgrade to a newer Better Auth version (which has safer parsing), consider wrapping the parsing in a try/catch locally to log and handle bad values. If the issue persists, please share the exact value stored in the column and any error stack trace—this will help pinpoint the problem. <!-- 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/a51b8d56-8f36-43bd-bd7e-2df85dcd5a08?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/a51b8d56-8f36-43bd-bd7e-2df85dcd5a08?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/a51b8d56-8f36-43bd-bd7e-2df85dcd5a08?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/a51b8d56-8f36-43bd-bd7e-2df85dcd5a08?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/a51b8d56-8f36-43bd-bd7e-2df85dcd5a08?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/a51b8d56-8f36-43bd-bd7e-2df85dcd5a08?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/a51b8d56-8f36-43bd-bd7e-2df85dcd5a08?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/4862)
Author
Owner

@himself65 commented on GitHub (Sep 24, 2025):

Could you provide a way to reproduce it?

<!-- gh-comment-id:3326328113 --> @himself65 commented on GitHub (Sep 24, 2025): Could you provide a way to reproduce it?
Author
Owner

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

@himself65

Registration route:

// /api/sso/register-and-get-sp-metadata
import { NextResponse } from 'next/server'
import { withAuth } from '@/utils/with-auth'
import { auth } from '@/utils/auth/auth'
import { headers } from 'next/headers'
import { generateSpMetadata } from '@/utils/server/generate-sp-metadata'

export const POST = withAuth(
  {
    permission: { entity: 'organizations', action: 'manage' }
  },
  async (req, _user, { body }) => {
    const { orgId, orgSlug, domain, entryPoint, cert, metadataXml } = body

    const providerId = `${orgSlug}-saml`
    const origin =
      req.headers.get('origin') ??
      process.env.NEXT_PUBLIC_AUTH_URL ??
      'https://atlas.worldvue.com'

    const entityId = `https://myapp/${orgSlug}-saml`
    const callbackUrl = `${origin}/api/auth/sso/saml2/callback/${providerId}`

    try {
      // ✅ Generate SP metadata before registering the provider
      const spMetadataXml = await generateSpMetadata({ entityId, callbackUrl })

      const samlConfig = {
        entryPoint,
        cert,
        callbackUrl,
        audience: entityId,
        wantAssertionsSigned: true,
        signatureAlgorithm: 'sha256',
        digestAlgorithm: 'sha256',
        identifierFormat:
          'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
        idpMetadata: {
          metadata: metadataXml
        },
        spMetadata: {
          metadata: spMetadataXml,
          binding: 'post'
        },
        mapping: {
          id: 'http://schemas.microsoft.com/identity/claims/objectidentifier',
          email:
            'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
          name: 'http://schemas.microsoft.com/identity/claims/displayname',
          firstName:
            'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
          lastName:
            'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'
        }
      }

      await auth.api.registerSSOProvider({
        body: {
          providerId,
          issuer: entryPoint,
          domain,
          organizationId: orgId,
          samlConfig
        },
        headers: await headers()
      })

      // No need to patch DB or fetch metadata again
      return NextResponse.json({ success: true, spMetadataXml })
    } catch (err) {
      console.error('[SSO REGISTER ERROR]', err)
      return NextResponse.json({ error: 'SSO setup failed' }, { status: 500 })
    }
  }
)

Schema:

	"sso_providers",
	{
		id: text("id").primaryKey(),
		issuer: text("issuer").notNull(),
		oidcConfig: text("oidc_config"),
		samlConfig: text("saml_config"),
		userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
		providerId: text("provider_id").notNull().unique(),
		organizationId: text("organization_id").references(() => organizations.id, { onDelete: "cascade" }),
		domain: text("domain").notNull(),
	},
	(t) => [
		index("sso_providers_issuer_idx").on(t.issuer),
		index("sso_providers_user_id_idx").on(t.userId),
		index("sso_providers_organization_id_idx").on(t.organizationId),
		unique("sso_providers_provider_id_key").on(t.providerId),
		unique("sso_providers_domain_key").on(t.domain),
	]
)

I'm custom generating the spMetadata as prescribed by someone in the discord. I just dont understand why when the schema says the saml and oidc config's should be stored as string/text, then when I try to use sign-in the JSON.parse doesn't work. Hence why I opened my PR to add safe JSON parsing based on how different adapters store data. Also im using drizzle with a postgres database.

<!-- gh-comment-id:3326349920 --> @natetewelde commented on GitHub (Sep 24, 2025): @himself65 Registration route: ``` // /api/sso/register-and-get-sp-metadata import { NextResponse } from 'next/server' import { withAuth } from '@/utils/with-auth' import { auth } from '@/utils/auth/auth' import { headers } from 'next/headers' import { generateSpMetadata } from '@/utils/server/generate-sp-metadata' export const POST = withAuth( { permission: { entity: 'organizations', action: 'manage' } }, async (req, _user, { body }) => { const { orgId, orgSlug, domain, entryPoint, cert, metadataXml } = body const providerId = `${orgSlug}-saml` const origin = req.headers.get('origin') ?? process.env.NEXT_PUBLIC_AUTH_URL ?? 'https://atlas.worldvue.com' const entityId = `https://myapp/${orgSlug}-saml` const callbackUrl = `${origin}/api/auth/sso/saml2/callback/${providerId}` try { // ✅ Generate SP metadata before registering the provider const spMetadataXml = await generateSpMetadata({ entityId, callbackUrl }) const samlConfig = { entryPoint, cert, callbackUrl, audience: entityId, wantAssertionsSigned: true, signatureAlgorithm: 'sha256', digestAlgorithm: 'sha256', identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', idpMetadata: { metadata: metadataXml }, spMetadata: { metadata: spMetadataXml, binding: 'post' }, mapping: { id: 'http://schemas.microsoft.com/identity/claims/objectidentifier', email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', name: 'http://schemas.microsoft.com/identity/claims/displayname', firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname', lastName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname' } } await auth.api.registerSSOProvider({ body: { providerId, issuer: entryPoint, domain, organizationId: orgId, samlConfig }, headers: await headers() }) // No need to patch DB or fetch metadata again return NextResponse.json({ success: true, spMetadataXml }) } catch (err) { console.error('[SSO REGISTER ERROR]', err) return NextResponse.json({ error: 'SSO setup failed' }, { status: 500 }) } } ) ``` Schema: ```export const ssoProviders = auth.table( "sso_providers", { id: text("id").primaryKey(), issuer: text("issuer").notNull(), oidcConfig: text("oidc_config"), samlConfig: text("saml_config"), userId: text("user_id").references(() => users.id, { onDelete: "cascade" }), providerId: text("provider_id").notNull().unique(), organizationId: text("organization_id").references(() => organizations.id, { onDelete: "cascade" }), domain: text("domain").notNull(), }, (t) => [ index("sso_providers_issuer_idx").on(t.issuer), index("sso_providers_user_id_idx").on(t.userId), index("sso_providers_organization_id_idx").on(t.organizationId), unique("sso_providers_provider_id_key").on(t.providerId), unique("sso_providers_domain_key").on(t.domain), ] ) ``` I'm custom generating the spMetadata as prescribed by someone in the discord. I just dont understand why when the schema says the saml and oidc config's should be stored as string/text, then when I try to use sign-in the JSON.parse doesn't work. Hence why I opened my PR to add safe JSON parsing based on how different adapters store data. Also im using drizzle with a postgres database.
Author
Owner

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

This has been an issue for us since 1.3.14, so it's highly likely this PR caused it, but did not investigate deeper.

https://github.com/better-auth/better-auth/pull/3660

All SSO logins are broken since that version. Given the state of the SSO plugin I'm not sure why this was declared as stable with v1.3.

<!-- gh-comment-id:3351282279 --> @GigaDroid commented on GitHub (Sep 30, 2025): This has been an issue for us since 1.3.14, so it's highly likely this PR caused it, but did not investigate deeper. https://github.com/better-auth/better-auth/pull/3660 All SSO logins are broken since that version. Given the state of the SSO plugin I'm not sure why this was declared as stable with v1.3.
Author
Owner

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

This has been an issue for us since 1.3.14, so it's highly likely this PR caused it, but did not investigate deeper.

#3660

All SSO logins are broken since that version. Given the state of the SSO plugin I'm not sure why this was declared as stable with v1.3.

Sorry to hear your SSO is broken, but relieved to know I’m not the only one whose SSO is broken after the updates. Was starting to go crazy

<!-- gh-comment-id:3351912225 --> @natetewelde commented on GitHub (Sep 30, 2025): > This has been an issue for us since 1.3.14, so it's highly likely this PR caused it, but did not investigate deeper. > > [#3660](https://github.com/better-auth/better-auth/pull/3660) > > All SSO logins are broken since that version. Given the state of the SSO plugin I'm not sure why this was declared as stable with v1.3. Sorry to hear your SSO is broken, but relieved to know I’m not the only one whose SSO is broken after the updates. Was starting to go crazy
Author
Owner

@GigaDroid commented on GitHub (Oct 1, 2025):

@Kinfe123 just wanted to check, are you aware of this?

<!-- gh-comment-id:3356874049 --> @GigaDroid commented on GitHub (Oct 1, 2025): @Kinfe123 just wanted to check, are you aware of this?
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#18723