DocumentsBetter Auth
SAML Authentication
SAML Authentication
Type
Topic
Status
Published
Created
Jul 7, 2026
Updated
Jul 7, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

SAML Authentication in Better Auth#

SAML authentication is part of the sso plugin (packages/sso/). The core types live in SAMLConfig, which holds the IdP entry point, signing cert, issuer, and the central callbackUrl field. SP and IdP objects are constructed per-request by createSP and createIdP in routes/helpers.ts. All SAML response validation — size check, signature, timestamp, audience, recipient, InResponseTo, replay protection, and session creation — runs through the unified processSAMLResponse() pipeline in saml-pipeline.ts.

Two HTTP endpoints both delegate to that pipeline :

EndpointMethodPurpose
/sso/saml2/callback/:providerIdGET + POSTSP-initiated callback; also handles IdP-initiated flows
/sso/saml2/sp/acs/:providerIdPOSTDedicated Assertion Consumer Service (SAML-standard path)

Origin checks are bypassed on both routes to allow the IdP to POST cross-origin .

Split Deployments (Separate Auth Server and Frontend Origin)#

In a split deployment the auth server (Better Auth baseURL) runs on a different origin than the frontend app. The SP Assertion Consumer Service URL is always on the auth server — the IdP must POST SAML responses there. The post-authentication browser redirect can then send the user to the frontend origin.

The mechanism is RelayState: when the SP-initiated login begins, the originating callbackURL (e.g. https://app.example.com/dashboard) is encoded into the RelayState. After the SAML assertion is validated and a session is created, the pipeline resolves the final redirect :

callbackUrl =
  relayState?.callbackURL // ← frontend URL from SP-initiated request
  || parsedSamlConfig.callbackUrl // ← fallback from provider config
  || ctx.context.baseURL // ← auth server root

For IdP-initiated flows (no RelayState), the fallback chain above means the redirect lands on parsedSamlConfig.callbackUrl — which, if set to the ACS endpoint URL, will be blocked by loop protection and fall back to baseURL instead (see next section). This is a known limitation with a TODO comment in the source .

To allow a cross-origin frontend URL as a redirect target it must be registered as a trusted origin so isTrustedOrigin() accepts it .

Dual-Purpose callbackUrl#

callbackUrl in SAMLConfig currently serves two distinct roles simultaneously:

  1. ACS Location — when no spMetadata.metadata XML is supplied, createSP registers callbackUrl as the SP's assertionConsumerService Location . This is the URL the IdP is told to POST SAML responses to.
  2. Post-auth redirect — after session creation, processSAMLResponse uses the same field as the browser redirect destination .

The fallback when callbackUrl is absent is ${baseURL}/sso/saml2/sp/acs/${providerId} . Both the /callback path and the /sp/acs path are always included in the set of expected SAML Recipient values during response validation , so the IdP can POST to either.

Known limitation: This conflation breaks IdP-initiated flows when callbackUrl is set to an app-level destination (e.g. /dashboard). The source carries an explicit TODO to split these into separate fields .

Redirect Loop Prevention in getSafeRedirectUrl#

getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) is called twice in the pipeline — once to determine the error redirect target and once for the final success redirect. It enforces three rules:

  1. No redirect target (url is undefined or empty) → return appOrigin.
  2. Relative paths: resolved against appOrigin; rejected if the resolved origin differs from appOrigin (protocol-relative // paths are rejected) .
  3. Absolute URLs: rejected unless isTrustedOrigin() approves the origin .

Loop prevention (the primary reason for this function): after passing the origin check, the URL's pathname is compared to the callbackPath . If they match, the function returns appOrigin instead. This blocks the scenario where callbackUrl was set to the ACS endpoint — e.g., https://auth.example.com/api/auth/sso/saml2/sp/acs/my-idp — which would otherwise cause the browser to loop back into SAML processing after authentication.

The callbackPath argument is the current endpoint path (/sso/saml2/callback/:id or /sso/saml2/sp/acs/:id), so the check fires regardless of which of the two routes received the IdP POST .

SAML Authentication | Dosu