#11296·better-auth

sso: SAML ACS from samlConfig.callbackUrl never reaches expectedRecipients (binding URN passed where samlify expects "post")

Author: YehudaBriskmanCreated Sep 15, 2026Updated Sep 16, 2026

Is this suited for github?

  • Yes, this is suited for github

Reproduction

Summary

With a SAML provider whose Assertion Consumer Service is not the plugin's own /sso/saml2/sp/acs/:providerId path, every login fails with SAML_RECIPIENT_MISMATCH, even though the configured ACS is correct and the IdP posts a valid, signed assertion to it.

The plugin looks the configured ACS up from samlify's SP metadata using the full binding URN. samlify keys that lookup by short binding name (post), so the lookup returns undefined and the configured ACS is dropped from the accepted recipients.

Current vs. Expected behavior

Versions

  • @better-auth/sso 1.6.25, and unchanged in 1.7.5 (latest at the time of writing)
  • better-auth 1.6.25
  • samlify 2.13.1

Where

@better-auth/sso dist/index.mjs:

javascript
const SAML_HTTP_POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
// ...
const assertionConsumerServiceUrl =
  sp.entityMeta.getAssertionConsumerService(SAML_HTTP_POST_BINDING);

samlify build/src/metadata-sp.js, SpMetadata.prototype.getAssertionConsumerService:

javascript
var bindName_1 = namespace.binding[binding];   // namespace.binding = { redirect, post, simpleSign, artifact }

namespace.binding["urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"] is undefined, no entry matches, and the function returns undefined. getExpectedSAMLRecipients(...) then filters that out. What is left is currentCallbackPath and ${baseURL}/sso/saml2/sp/acs/${providerId}, which are the same URL.

Reproduce

  1. Register a SAML provider with samlConfig.callbackUrl set to an ACS that is not the plugin path, e.g. https://app.example/callback, and no spMetadata.metadata XML.
  2. At the IdP, register https://app.example/callback as the relying party's ACS.
  3. Forward that endpoint's POST to the plugin's ACS handler, and sign in.

Expected: the assertion's Recipient (https://app.example/callback) is accepted.

Actual:

code: 'SAML_RECIPIENT_MISMATCH',
expectedRecipients: [
  'https://app.example/api/auth/sso/saml2/sp/acs/<providerId>',
  'https://app.example/api/auth/sso/saml2/sp/acs/<providerId>'
]

Fix

Pass samlify's short binding key:

diff
- sp.entityMeta.getAssertionConsumerService(SAML_HTTP_POST_BINDING)
+ sp.entityMeta.getAssertionConsumerService("post")

getSAMLPostAssertionConsumerServiceUrls, which parses supplied SP metadata XML, already compares the full URN correctly. So supplying spMetadata.metadata works around it, and only the callbackUrl path is affected.

What version of Better Auth are you using?

  • @better-auth/sso 1.6.25, and unchanged in 1.7.5 (latest at the time of writing) - better-auth 1.6.25 - samlify 2.13.1

System info

bash
### Versions

- `@better-auth/sso` 1.6.25, and unchanged in 1.7.5 (latest at the time of writing)
- `better-auth` 1.6.25
- `samlify` 2.13.1

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

Package

Auth config (if applicable)

typescript
import { betterAuth } from "better-auth"
export const auth = betterAuth({
  emailAndPassword: {  
    enabled: true
  },
});

Additional context

Why it matters

A deployment whose IdP already has a registered ACS, commonly an enterprise AD-FS trust that is costly to change, cannot adapt to it. The one configuration knob meant for that case is silently ignored, and the failure names neither the configured ACS nor the lookup that lost it.