IdP-initiated SAML SSO returns 404 #2486

Closed
opened 2026-03-13 09:58:30 -05:00 by GiteaMirror · 6 comments
Owner

Originally created by @EthanKim88 on GitHub (Dec 8, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Set up Better Auth with standard catch-all route:
// app/api/auth/[...all]/route.ts
export const { GET, POST } = toNextJsHandler(auth.handler)
  1. Register a SAML SSO provider with:
  1. Test SP-initiated flow:
  1. Test IdP-initiated flow:
  • Click app icon from Okta dashboard
  • Okta POSTs SAMLResponse to callback URL
  • Better Auth processes and returns 302 redirect to same callback URL
  • Browser follows 302 with GET request
  • Result: 404 Not Found (catch-all doesn't match the specific path)
  1. Workaround: Create explicit route with BOTH GET and POST:
// app/api/auth/sso/saml2/callback/[providerId]/route.ts
export async function POST(req: NextRequest) {
  return auth.handler(req)
}

export async function GET() {
  // Required because POST returns 302 to same URL
  return NextResponse.redirect(new URL('/', process.env.BETTER_AUTH_URL))
}
  • POST only → 405 Method Not Allowed (GET fails)
  • GET only → POST fails
  • Both → Works
  1. Request flow (from server logs):
  • POST /api/auth/sso/saml2/callback/my-provider 302
  • GET /api/auth/sso/saml2/callback/my-provider 307 (redirect to /)
  • GET / 200

Current vs. Expected behavior

Current vs. Expected behavior

Current behavior:

  1. Register SAML provider with catch-all route only (app/api/auth/[...all]/route.ts)
  2. IdP-initiated POST to callback URL
  3. Better Auth processes and returns 302 redirect to same callback URL
  4. Browser follows 302 with GET request
  5. GET returns 404 (catch-all not matched) or 405 (if explicit POST-only route exists)

Expected behavior:
Better Auth should either:

  1. Return 302 redirect to a different URL (like / or configured callback)
  2. Or document that both GET and POST handlers are required for SAML callback

What version of Better Auth are you using?

better-auth: ^1.4.4, @better-auth/sso: ^1.4.4 (included in better-auth), Next.js: 15.5.7, React: 19.1.0, Node.js: v24.10.0

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64"
  },
  "node": {
    "version": "v24.10.0"
  },
  "packageManager": {
    "name": "npm",
    "version": "11.6.0"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "15.5.7"
    },
    {
      "name": "react",
      "version": "19.1.0"
    }
  ],
  "databases": [
    {
      "name": "@prisma/client",
      "version": "6.16.2"
    }
  ],
  "betterAuth": {
    "version": "^1.4.4",
    "config": {
      "appName": "[REDACTED]",
      "baseURL": "[REDACTED]",
      "advanced": {
        "disableCSRFCheck": true
      },
      "session": {
        "cookieCache": {
          "enabled": true,
          "maxAge": 300
        },
        "expiresIn": 604800,
        "updateAge": 86400
      },
      "plugins": [
        { "name": "next-cookies" },
        { "name": "organization" },
        {
          "name": "sso",
          "config": {
            "id": "sso",
            "schema": {
              "ssoProvider": {
                "fields": {
                  "issuer": { "type": "string", "required": true },
                  "samlConfig": { "type": "string", "required": false },
                  "providerId": { "type": "string", "required": true, "unique": true },
                  "organizationId": { "type": "string", "required": false },
                  "domain": { "type": "string", "required": true }
                }
              }
            }
          }
        }
      ]
    }
  }
}

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

Client, Documentation, Backend

Auth config (if applicable)

// lib/auth.ts (sanitized for GitHub issue)
import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import { nextCookies } from 'better-auth/next-js'
import { organization } from 'better-auth/plugins'
import { sso } from '@better-auth/sso'

export const auth = betterAuth({
  appName: 'My App',
  baseURL: process.env.BETTER_AUTH_URL, // e.g., https://example.com

  database: prismaAdapter(prisma, {
    provider: 'postgresql',
  }),

  session: {
    cookieCache: {
      enabled: true,
      maxAge: 60 * 5,
    },
    expiresIn: 60 * 60 * 24 * 7,
    updateAge: 60 * 60 * 24,
  },

  plugins: [
    nextCookies(),
    organization({
      // ... organization config
    }),
    sso({
      providerRegistration: {
        disabled: false,
      },
      organizationProvisioning: {
        disabled: false,
        defaultRole: 'member',
      },
      errorCallbackURL: '/auth/sign-in',
    }),
  ],

  secret: process.env.BETTER_AUTH_SECRET,
})

// SAML Provider registration example
await auth.api.registerSSOProvider({
  body: {
    providerId: 'my-okta-provider',
    issuer: 'https://example.com',
    domain: 'example.com',
    samlConfig: {
      issuer: 'https://example.com',
      entryPoint: 'https://my-company.okta.com/app/xxx/sso/saml',
      cert: '[REDACTED - IdP Certificate]',
      callbackUrl: 'https://example.com/api/auth/sso/saml2/callback/my-okta-provider',
      audience: 'https://example.com',
      wantAssertionsSigned: true,
      signatureAlgorithm: 'sha256',
      identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
      idpMetadata: {
        entityID: 'http://www.okta.com/xxx',
        cert: '[REDACTED]',
        singleSignOnService: [{
          Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
          Location: 'https://my-company.okta.com/app/xxx/sso/saml',
        }],
      },
      spMetadata: {
        entityID: 'https://example.com',
        binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
      },
      mapping: {
        id: 'nameID',
        email: 'email',
        name: 'displayName',
      },
    },
  },
})

Additional context

Why IdP-initiated SSO matters:

  • Enterprise customers often access apps from their IdP dashboard (Okta, Azure AD, Google Workspace)
  • IT admins prefer centralized app access through IdP portals
  • Many enterprise SSO requirements mandate IdP-initiated flow support

Real-world debugging experience:

I spent several hours debugging why SSO worked perfectly in my local environment but customers reported 404 errors. The challenge was:

  • SP-initiated flow (which I tested locally) worked fine
  • IdP-initiated flow (which customers used from their Okta dashboard) failed with 404
  • I couldn't ask enterprise customers to open DevTools and debug for me
  • The only feedback I had was "it shows 404"

The root cause was that Better Auth's POST handler returns 302 redirect to the same callback URL, which requires a GET handler that doesn't exist by default.

Adding documentation or auto-registering this callback route would save other developers from the same confusion.

Originally created by @EthanKim88 on GitHub (Dec 8, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Set up Better Auth with standard catch-all route: ```typescript // app/api/auth/[...all]/route.ts export const { GET, POST } = toNextJsHandler(auth.handler) ``` 2. Register a SAML SSO provider with: - callbackUrl: https://example.com/api/auth/sso/saml2/callback/my-provider 3. Test SP-initiated flow: - Call authClient.signIn.sso({ email: "user@example.com" }) - Result: ✅ Works 4. Test IdP-initiated flow: - Click app icon from Okta dashboard - Okta POSTs SAMLResponse to callback URL - Better Auth processes and returns 302 redirect to same callback URL - Browser follows 302 with GET request - Result: ❌ 404 Not Found (catch-all doesn't match the specific path) 5. Workaround: Create explicit route with BOTH GET and POST: ```ts // app/api/auth/sso/saml2/callback/[providerId]/route.ts export async function POST(req: NextRequest) { return auth.handler(req) } export async function GET() { // Required because POST returns 302 to same URL return NextResponse.redirect(new URL('/', process.env.BETTER_AUTH_URL)) } ``` - POST only → 405 Method Not Allowed (GET fails) - GET only → POST fails - Both → ✅ Works 6. Request flow (from server logs): - POST /api/auth/sso/saml2/callback/my-provider 302 - GET /api/auth/sso/saml2/callback/my-provider 307 (redirect to `/`) - GET / 200 ### Current vs. Expected behavior ## Current vs. Expected behavior **Current behavior:** 1. Register SAML provider with catch-all route only (`app/api/auth/[...all]/route.ts`) 2. IdP-initiated POST to callback URL 3. Better Auth processes and returns **302 redirect to same callback URL** 4. Browser follows 302 with GET request 5. **GET returns 404** (catch-all not matched) or **405** (if explicit POST-only route exists) **Expected behavior:** Better Auth should either: 1. Return 302 redirect to a different URL (like `/` or configured callback) 2. Or document that both GET and POST handlers are required for SAML callback ### What version of Better Auth are you using? better-auth: ^1.4.4, @better-auth/sso: ^1.4.4 (included in better-auth), Next.js: 15.5.7, React: 19.1.0, Node.js: v24.10.0 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64" }, "node": { "version": "v24.10.0" }, "packageManager": { "name": "npm", "version": "11.6.0" }, "frameworks": [ { "name": "next", "version": "15.5.7" }, { "name": "react", "version": "19.1.0" } ], "databases": [ { "name": "@prisma/client", "version": "6.16.2" } ], "betterAuth": { "version": "^1.4.4", "config": { "appName": "[REDACTED]", "baseURL": "[REDACTED]", "advanced": { "disableCSRFCheck": true }, "session": { "cookieCache": { "enabled": true, "maxAge": 300 }, "expiresIn": 604800, "updateAge": 86400 }, "plugins": [ { "name": "next-cookies" }, { "name": "organization" }, { "name": "sso", "config": { "id": "sso", "schema": { "ssoProvider": { "fields": { "issuer": { "type": "string", "required": true }, "samlConfig": { "type": "string", "required": false }, "providerId": { "type": "string", "required": true, "unique": true }, "organizationId": { "type": "string", "required": false }, "domain": { "type": "string", "required": true } } } } } } ] } } } ``` ### Which area(s) are affected? (Select all that apply) Client, Documentation, Backend ### Auth config (if applicable) ```typescript // lib/auth.ts (sanitized for GitHub issue) import { betterAuth } from 'better-auth' import { prismaAdapter } from 'better-auth/adapters/prisma' import { nextCookies } from 'better-auth/next-js' import { organization } from 'better-auth/plugins' import { sso } from '@better-auth/sso' export const auth = betterAuth({ appName: 'My App', baseURL: process.env.BETTER_AUTH_URL, // e.g., https://example.com database: prismaAdapter(prisma, { provider: 'postgresql', }), session: { cookieCache: { enabled: true, maxAge: 60 * 5, }, expiresIn: 60 * 60 * 24 * 7, updateAge: 60 * 60 * 24, }, plugins: [ nextCookies(), organization({ // ... organization config }), sso({ providerRegistration: { disabled: false, }, organizationProvisioning: { disabled: false, defaultRole: 'member', }, errorCallbackURL: '/auth/sign-in', }), ], secret: process.env.BETTER_AUTH_SECRET, }) // SAML Provider registration example await auth.api.registerSSOProvider({ body: { providerId: 'my-okta-provider', issuer: 'https://example.com', domain: 'example.com', samlConfig: { issuer: 'https://example.com', entryPoint: 'https://my-company.okta.com/app/xxx/sso/saml', cert: '[REDACTED - IdP Certificate]', callbackUrl: 'https://example.com/api/auth/sso/saml2/callback/my-okta-provider', audience: 'https://example.com', wantAssertionsSigned: true, signatureAlgorithm: 'sha256', identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', idpMetadata: { entityID: 'http://www.okta.com/xxx', cert: '[REDACTED]', singleSignOnService: [{ Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', Location: 'https://my-company.okta.com/app/xxx/sso/saml', }], }, spMetadata: { entityID: 'https://example.com', binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', }, mapping: { id: 'nameID', email: 'email', name: 'displayName', }, }, }, }) ``` ### Additional context Why IdP-initiated SSO matters: - Enterprise customers often access apps from their IdP dashboard (Okta, Azure AD, Google Workspace) - IT admins prefer centralized app access through IdP portals - Many enterprise SSO requirements mandate IdP-initiated flow support **Real-world debugging experience:** I spent several hours debugging why SSO worked perfectly in my local environment but customers reported 404 errors. The challenge was: - SP-initiated flow (which I tested locally) worked fine - IdP-initiated flow (which customers used from their Okta dashboard) failed with 404 - I couldn't ask enterprise customers to open DevTools and debug for me - The only feedback I had was "it shows 404" The root cause was that Better Auth's POST handler returns 302 redirect to the same callback URL, which requires a GET handler that doesn't exist by default. Adding documentation or auto-registering this callback route would save other developers from the same confusion.
GiteaMirror added the nextjsdocumentation labels 2026-03-13 09:58:30 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Dec 8, 2025):

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

The 404 after IdP-initiated SAML SSO is caused by how Better Auth and Next.js handle the SAML callback route. The SAML callback endpoint (/sso/saml2/callback/:providerId) is implemented as a POST-only route in Better Auth. When the IdP (like Okta) POSTs the SAMLResponse, Better Auth processes it and issues a 302 redirect—often to the same callback URL. The browser then follows up with a GET request to that URL. If you don't have a GET handler for that route, Next.js returns a 404 or 405 error because the POST handler alone can't handle the GET request. This is why SP-initiated flows (which don't trigger this POST→GET redirect) work fine, but IdP-initiated flows break.

This requirement to have both GET and POST handlers for the SAML callback route is not currently documented in Better Auth, but it's a known integration gap and has been discussed in issues and code reviews (example, source). The recommended workaround is to create an explicit route for the SAML callback URL with both GET and POST handlers, as you've done. This ensures the browser's follow-up GET request after the 302 redirect is handled correctly.

Improved documentation or auto-registration of both handlers would help avoid this confusion for others. For now, the explicit route with both methods is the best solution.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

@dosubot[bot] commented on GitHub (Dec 8, 2025): <!-- Greeting --> Hi @EthanKim88! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> The 404 after IdP-initiated SAML SSO is caused by how Better Auth and Next.js handle the SAML callback route. The SAML callback endpoint (`/sso/saml2/callback/:providerId`) is implemented as a POST-only route in Better Auth. When the IdP (like Okta) POSTs the SAMLResponse, Better Auth processes it and issues a 302 redirect—often to the same callback URL. The browser then follows up with a GET request to that URL. If you don't have a GET handler for that route, Next.js returns a 404 or 405 error because the POST handler alone can't handle the GET request. This is why SP-initiated flows (which don't trigger this POST→GET redirect) work fine, but IdP-initiated flows break. This requirement to have both GET and POST handlers for the SAML callback route is not currently documented in Better Auth, but it's a known integration gap and has been discussed in issues and code reviews ([example](https://github.com/better-auth/better-auth/issues/3330#issuecomment-3057326240), [source](https://github.com/better-auth/better-auth/blob/b1929e7e27a044b9f5d50c687c35032d84dba4e0/packages/sso/src/routes/sso.ts#L1084-L2177)). The recommended workaround is to create an explicit route for the SAML callback URL with both GET and POST handlers, as you've done. This ensures the browser's follow-up GET request after the 302 redirect is handled correctly. Improved documentation or auto-registration of both handlers would help avoid this confusion for others. For now, the explicit route with both methods is the best solution. <!-- 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/c0ac24c3-37e0-4c46-b77c-adcfdc4a4340?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/c0ac24c3-37e0-4c46-b77c-adcfdc4a4340?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/c0ac24c3-37e0-4c46-b77c-adcfdc4a4340?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/c0ac24c3-37e0-4c46-b77c-adcfdc4a4340?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/c0ac24c3-37e0-4c46-b77c-adcfdc4a4340?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/c0ac24c3-37e0-4c46-b77c-adcfdc4a4340?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/c0ac24c3-37e0-4c46-b77c-adcfdc4a4340?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=The%20404%20after%20IdP-initiated%20SAML%20SSO%20is%20caused%20by%20how%20Better%20Auth%20and%20Next.js%20handle%20the%20SAML%20callback%20route.%20The%20SAML%20callback%20endpoint%20%28%60/sso/saml2/callback/%3AproviderId%60%29%20is%20implemented%20as%20a%20POST-only%20route%20in%20Better%20Auth.%20When%20the%20IdP%20%28like%20Okta%29%20POSTs%20the%20SAMLResponse%2C%20Better%20Auth%20processes%20it%20and%20issues%20a%20302%20redirect%E2%80%94often%20to%20the%20same%20callback%20URL.%20The%20browser%20then%20follows%20up%20with%20a%20GET%20request%20to%20that%20URL.%20If%20you%20don%27t%20have%20a%20GET%20handler%20for%20that%20route%2C%20Next.js%20returns%20a%20404%20or%20405%20error%20because%20the%20POST%20handler%20alone%20can%27t%20handle%20the%20GET%20request.%20This%20is%20why%20SP-initiated%20flows%20%28which%20don%27t%20trigger%20this%20POST%E2%86%92GET%20redirect%29%20work%20fine%2C%20but%20IdP-initiated%20flows%20break.%0A%0AThis%20requirement%20to%20have%20both%20GET%20and%20POST%20handlers%20for%20the%20SAML%20callback%20route%20is%20not%20currently%20documented%20in%20Better%20Auth%2C%20but%20it%27s%20a%20known%20integration%20gap%20and%20has%20been%20discussed%20in%20issues%20and%20code%20reviews%20%28%5Bexample%5D%28https%3A//github.com/better-auth/better-auth/issues/3330%23issuecomment-3057326240%29%2C%20%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/blob/b1929e7e27a044b9f5d50c687c35032d84dba4e0/packages/sso/src/routes/sso.ts%23L1084-L2177%29%29.%20The%20recommended%20workaround%20is%20to%20create%20an%20explicit%20route%20for%20the%20SAML%20callback%20URL%20with%20both%20GET%20and%20POST%20handlers%2C%20as%20you%27ve%20done.%20This%20ensures%20the%20browser%27s%20follow-up%20GET%20request%20after%20the%20302%20redirect%20is%20handled%20correctly.%0A%0AImproved%20documentation%20or%20auto-registration%20of%20both%20handlers%20would%20help%20avoid%20this%20confusion%20for%20others.%20For%20now%2C%20the%20explicit%20route%20with%20both%20methods%20is%20the%20best%20solution.)&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/6615)
Author
Owner

@EthanKim88 commented on GitHub (Dec 8, 2025):

@dosu Thanks for the clarification!

Good to know this is a known issue. I hope the documentation gets updated or the auto-registration feature gets implemented in a future release to help other developers avoid this confusion.

For now, my workaround with explicit GET + POST handlers is working fine as you mentioned. :)

@EthanKim88 commented on GitHub (Dec 8, 2025): @dosu Thanks for the clarification! Good to know this is a known issue. I hope the documentation gets updated or the auto-registration feature gets implemented in a future release to help other developers avoid this confusion. For now, my workaround with explicit GET + POST handlers is working fine as you mentioned. :)
Author
Owner

@dosubot[bot] commented on GitHub (Dec 8, 2025):

Happy to help! If you run into anything else or have more questions down the line, just let me know.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

@dosubot[bot] commented on GitHub (Dec 8, 2025): <!-- Answer --> Happy to help! If you run into anything else or have more questions down the line, just let me know. <!-- 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/71c88ae6-f3a2-48b5-a6db-1aaa2302002a?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/71c88ae6-f3a2-48b5-a6db-1aaa2302002a?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/71c88ae6-f3a2-48b5-a6db-1aaa2302002a?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/71c88ae6-f3a2-48b5-a6db-1aaa2302002a?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/71c88ae6-f3a2-48b5-a6db-1aaa2302002a?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/71c88ae6-f3a2-48b5-a6db-1aaa2302002a?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/71c88ae6-f3a2-48b5-a6db-1aaa2302002a?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=Happy%20to%20help%21%20If%20you%20run%20into%20anything%20else%20or%20have%20more%20questions%20down%20the%20line%2C%20just%20let%20me%20know.)&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/6615)
Author
Owner

@GautamBytes commented on GitHub (Dec 10, 2025):

Will update the documentation soon!

@GautamBytes commented on GitHub (Dec 10, 2025): Will update the documentation soon!
Author
Owner

@EthanKim88 commented on GitHub (Dec 10, 2025):

@dosu

To clarify for future readers:

The 302 redirect doesn't always go to the same callback URL. The actual logic is:

const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
  • SP-initiated flow: RelayState exists (e.g., /dashboard) → redirects there → works
  • IdP-initiated flow: RelayState is empty → falls back to parsedSamlConfig.callbackUrl (which is the ACS URL itself)
    → GET request to same endpoint → 404

The root cause is that parsedSamlConfig.callbackUrl is the ACS URL (per SAML spec), not an application URL. When used as a redirect target, it causes the browser to GET the same endpoint that only has a POST handler.

See https://github.com/better-auth/better-auth/blob/main/packages/sso/src/routes/sso.ts#L1458-L1487 for reference.

@EthanKim88 commented on GitHub (Dec 10, 2025): @dosu To clarify for future readers: The 302 redirect doesn't **always** go to the same callback URL. The actual logic is: ```typescript const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL; ``` - SP-initiated flow: RelayState exists (e.g., /dashboard) → redirects there → ✅ works - IdP-initiated flow: RelayState is empty → falls back to parsedSamlConfig.callbackUrl (which is the ACS URL itself) → GET request to same endpoint → 404 The root cause is that parsedSamlConfig.callbackUrl is the ACS URL (per SAML spec), not an application URL. When used as a redirect target, it causes the browser to GET the same endpoint that only has a POST handler. See https://github.com/better-auth/better-auth/blob/main/packages/sso/src/routes/sso.ts#L1458-L1487 for reference.
Author
Owner

@dosubot[bot] commented on GitHub (Dec 10, 2025):

Great clarification—your summary matches the actual redirect logic in Better Auth SAML SSO: const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL. In SP-initiated flows, RelayState is set (like /dashboard), so the redirect works as expected. In IdP-initiated flows, RelayState is empty, so Better Auth falls back to parsedSamlConfig.callbackUrl, which is the ACS URL. Since the ACS URL is only meant for POSTs (per SAML spec), the browser's follow-up GET request hits a POST-only endpoint and returns a 404.

This is a known integration gap—Better Auth doesn't auto-register GET handlers for the ACS endpoint, so you need to explicitly add both GET and POST handlers for the callback route in Next.js to handle the redirect flow (PR #6669, code reference). The workaround is documented in the proposed docs update, but not yet merged.

For future readers: If you see 404s after IdP-initiated SAML SSO, add explicit GET and POST handlers for your SAML callback route. You can't change the callbackUrl to a different endpoint, since it must be the ACS URL by spec (PR #6669).

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

@dosubot[bot] commented on GitHub (Dec 10, 2025): <!-- Answer --> Great clarification—your summary matches the actual redirect logic in Better Auth SAML SSO: `const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL`. In SP-initiated flows, RelayState is set (like `/dashboard`), so the redirect works as expected. In IdP-initiated flows, RelayState is empty, so Better Auth falls back to `parsedSamlConfig.callbackUrl`, which is the ACS URL. Since the ACS URL is only meant for POSTs (per SAML spec), the browser's follow-up GET request hits a POST-only endpoint and returns a 404. This is a known integration gap—Better Auth doesn't auto-register GET handlers for the ACS endpoint, so you need to explicitly add both GET and POST handlers for the callback route in Next.js to handle the redirect flow ([PR #6669](https://github.com/better-auth/better-auth/pull/6669), [code reference](https://github.com/better-auth/better-auth/blob/main/packages/sso/src/routes/sso.ts#L1458-L1487)). The workaround is documented in the proposed docs update, but not yet merged. For future readers: If you see 404s after IdP-initiated SAML SSO, add explicit GET and POST handlers for your SAML callback route. You can't change the callbackUrl to a different endpoint, since it must be the ACS URL by spec ([PR #6669](https://github.com/better-auth/better-auth/pull/6669)). <!-- 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/b665bebe-258c-4901-83c0-799943f5c6ac?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/b665bebe-258c-4901-83c0-799943f5c6ac?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/b665bebe-258c-4901-83c0-799943f5c6ac?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/b665bebe-258c-4901-83c0-799943f5c6ac?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/b665bebe-258c-4901-83c0-799943f5c6ac?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/b665bebe-258c-4901-83c0-799943f5c6ac?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/b665bebe-258c-4901-83c0-799943f5c6ac?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=Great%20clarification%E2%80%94your%20summary%20matches%20the%20actual%20redirect%20logic%20in%20Better%20Auth%20SAML%20SSO%3A%20%60const%20callbackUrl%20%3D%20RelayState%20%7C%7C%20parsedSamlConfig.callbackUrl%20%7C%7C%20ctx.context.baseURL%60.%20In%20SP-initiated%20flows%2C%20RelayState%20is%20set%20%28like%20%60/dashboard%60%29%2C%20so%20the%20redirect%20works%20as%20expected.%20In%20IdP-initiated%20flows%2C%20RelayState%20is%20empty%2C%20so%20Better%20Auth%20falls%20back%20to%20%60parsedSamlConfig.callbackUrl%60%2C%20which%20is%20the%20ACS%20URL.%20Since%20the%20ACS%20URL%20is%20only%20meant%20for%20POSTs%20%28per%20SAML%20spec%29%2C%20the%20browser%27s%20follow-up%20GET%20request%20hits%20a%20POST-only%20endpoint%20and%20returns%20a%20404.%20%0A%0AThis%20is%20a%20known%20integration%20gap%E2%80%94Better%20Auth%20doesn%27t%20auto-register%20GET%20handlers%20for%20the%20ACS%20endpoint%2C%20so%20you%20need%20to%20explicitly%20add%20both%20GET%20and%20POST%20handlers%20for%20the%20callback%20route%20in%20Next.js%20to%20handle%20the%20redirect%20flow%20%28%5BPR%20%236669%5D%28https%3A//github.com/better-auth/better-auth/pull/6669%29%2C%20%5Bcode%20reference%5D%28https%3A//github.com/better-auth/better-auth/blob/main/packages/sso/src/routes/sso.ts%23L1458-L1487%29%29.%20The%20workaround%20is%20documented%20in%20the%20proposed%20docs%20update%2C%20but%20not%20yet%20merged.%20%0A%0AFor%20future%20readers%3A%20If%20you%20see%20404s%20after%20IdP-initiated%20SAML%20SSO%2C%20add%20explicit%20GET%20and%20POST%20handlers%20for%20your%20SAML%20callback%20route.%20You%20can%27t%20change%20the%20callbackUrl%20to%20a%20different%20endpoint%2C%20since%20it%20must%20be%20the%20ACS%20URL%20by%20spec%20%28%5BPR%20%236669%5D%28https%3A//github.com/better-auth/better-auth/pull/6669%29%29.)&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/6615)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2486