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 :
| Endpoint | Method | Purpose |
|---|---|---|
/sso/saml2/callback/:providerId | GET + POST | SP-initiated callback; also handles IdP-initiated flows |
/sso/saml2/sp/acs/:providerId | POST | Dedicated 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:
- ACS Location — when no
spMetadata.metadataXML is supplied,createSPregisterscallbackUrlas the SP'sassertionConsumerServiceLocation . This is the URL the IdP is told to POST SAML responses to. - Post-auth redirect — after session creation,
processSAMLResponseuses 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:
- No redirect target (
urlisundefinedor empty) → returnappOrigin. - Relative paths: resolved against
appOrigin; rejected if the resolved origin differs fromappOrigin(protocol-relative//paths are rejected) . - 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 .